必威体育Betway必威体育官网
当前位置:首页 > IT技术

Java虚拟机

时间:2019-08-04 04:11:05来源:IT技术作者:seo实验室小编阅读:55次「手机版」
 

java虚拟机

JVM虚拟机

1. JVM中内存结构

1.1 JVM的数据区域

JVM所管理的内存区域大致划分为一下几类:java堆、方法区、虚拟机栈、本地方法栈、程序计数器、运行时常量池、直接内存等等。大致划分如下图所示:

其中,Java堆和方法区属于所有线程共享的内存空间。虚拟机栈、本地方法栈、程序计数器是各线程私有的内存空间。

Java堆:在JVM启动的时候建立,它是Java程序最主要的内存工作空间。该区中存放对象实例,几乎所有的Java对象实例都存放在Java堆中。Java堆是垃圾手机器管理的主要区域,因此也被叫做GC堆。

由于对象回收普遍采用分代算法,所以Java堆中也可细分为新生代和老生代。其中新生代分为eden区,s0区,S1区等,S0和S1也被称为from、to区域。绝大多数情况下,对象首先被分配在eden区,在新生代回收后,如果对象还存活则进入S0或S1区。之后每经过一次新生代回收,若仍存活则年龄加一。当达到一定条件后,就被认为是老年对象,从而进入老年代。

方法区:该区域用于存储JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等。通常来讲,这部分区域也可成为“永久代”,这部分区域的数据出现垃圾回收的行为比较少,主要针对常量池的回收和类型的卸载。运行时常量池就属于方法区的一部分。

直接内存区域:并不属于JVM运行时数据区的一部分,是在Java堆外的直接向系统申请的内存区间。因为访问速度快于Java堆,故而读写频繁的场合会考虑使用直接内存区域。

程序计数器:可看成当前线程所执行的字节码的行号指示器。通过计数器的值来定位到下一条需要运行的指令。

java虚拟机栈:这里描述的是Java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧来存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成的过程就对应着一个栈帧在虚拟机栈的入栈到出栈过程。每次函数调用的数据都是通过Java栈传递的。

本地方法栈:顾名思义,它用于本地方法调用。与虚拟机栈很相似,虚拟机栈为虚拟机执行Java方法服务,本地方法栈则为虚拟机使用的Native方法服务。

垃圾回收系统主要对方法区、Java堆和直接内存进行回收。其中Java堆是垃圾回收器的工作重点。

1.2 对象的创建

首先JVM遇到new指令时,先去检查执行的参数能否在常量池定位到一个类的符号引用,并检查其所代表的类是否被加载、连接和初始化。若没有则先执行类加载过程。

类加载检查完毕后,JVM先为新生对象分配内存,分配方式有:指针碰撞、空闲列表两种。内存分配好后,JVM将内存空间都初始化为0值(不包括对象头),如此则实例字段在Java程序中不必赋初始值就能使用。

接下来就是对象头的设置,包括设置哪个类的实例、对象的哈希码、GC分代信息、是否使用偏向锁等。

最后执行init方法,按Java程序中定义的初始化内容进行初始化。

1.3 对象的内存布局

对象在内存中存储的布局可分为3个区域:对象头、实例数据、对齐填充。

对象头:一部分存放对象自身的运行时数据,包括哈希码、GC分代信息、锁状态标志、偏向线程ID等信息,称为“Mark word”。另一部分是类型指针,即对象指向它的类元数据的指针,JVM通过指针来确定对象是哪个类的实例。如果是Java数组,还包括一块用于记录数组长度的数据区域。

实例数据:存储对象真正的有效信息,也就是程序中定义的各个类型的字段内容。无论是父类中继承下来的,还是子类自己定义的,都会统一记载。

对齐填充:无特殊含义,仅起占位符作用。JVM要求对象起始地址必须是8字节的整数倍。因此部分实例数据未对齐时,通过对齐填充来补充。

1.4 对象的访问定位

Java程序通过java栈上的reference数据来操作栈上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。

使用句柄:最大的好处是reference中存储稳定的句柄地址,对象移动只改变句柄中的实例对象指针,不改变reference本身。

直接指针:好处是访问数度快,节省了一次指针定位的时间开销。

1.5 常见内存溢出分类

Java堆溢出:最常见的溢出异常,例如将最小堆参数(-Xms)和最大堆(-Xmx)限定为同一值,同时不断创建新对象,那么就容易出现堆溢出异常。

方法区和运行时常量池的溢出:方法区存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。当运行时产生大量的类去填充方法区时,就有可能出现方法区内存溢出。

虚拟机栈和本地方法栈溢出:一种是线程请求的栈深度>虚拟机所允许的最大深度,报StackOverFlowERROR异常。另一种是JVM扩展栈时无法申请到足够内存,报outofmemoryError异常。

2. 常用JVM参数

-XX:+printGC

启动JVM后,只要遇到GC都会打印日志

-XX:+PrintGCDetails

打印GC详细信息,JVM退出前也会打印堆的信息

-XX:+PrintHeapAtGC

在每次GC前后分别打印堆的信息

-XX:+PrintGCTimeStamps

在每次GC时额外输出GC发生的时间

-XX:+TraceClassLoading

跟踪类的加载

-XX:+TraceClassUnLoading

跟踪类的卸载

-XX:+PrintVMOptions

打印JVM接受到的命令行显示参数

-Xss

设置线程的栈的大小

-Xms

设置初始堆的大小

-Xmx

设置最大堆的大小

-XX:+SurvivorRatio

设置新生代中eden和from/to的比例空间

-XX:+NewRatio

设置新生代和老年代的比例

-XX: +HeapDumpOnOutMemoryError

内存溢出时导出整个堆信息

3. 垃圾回收的方法

内存中的垃圾特指内存中不会再被使用的对象,回收是指将这类不再被使用的对象清理掉,释放其占用的内存空间。如果不及时清理掉内存中的垃圾,那么这些垃圾对象占用的内存空间将一直保留到应用程序结束,最终内存占用越来越多,可能导致溢出。

3.1 引用及可触及性分析

Java中提供有4个级别的引用:强引用、软引用、弱引用和虚引用。

强引用:是指程序中普遍存在的引用类型,强引用的对象是可触及的,不会被回收。相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下都是可以被回收的。

软引用:可被回收的引用。 软引用使用java.lang.ref.SoftReference类实现,一个对象只持有软引用,那么当堆空间不足时,就会被回收。

弱引用:发现即回收。弱引用使用java.lang.ref.weakreference类实现,在系统GC时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。

虚引用:对象回收跟踪。一个持有虚引用的对象和没有引用几乎是一样的,随时都会被回收。并且虚引用必须和引用队列一起使用,它的作用在于跟踪垃圾回收过程。当垃圾回收器准备回收一个对象时,发现它还有虚引用,就会在对象回收之后将虚引用加入到引用队列,以通知应用程序该对象的回收情况。

3.2 对象存活与否分析

1)引用计数法

引用计数法是最经典垃圾回收方法。其主要思想是每当任何一个对象引用A,则将A的引用计数器加1,当引用失效时,计数器减1。只要对象A的引用计数器的值为0,则A不可能再被使用,可考虑清除对象A。

该方法存在的两个严重问题:

1. 无法处理循环引用的情况,当A、B互相引用的时候无法清除。

2. 每次产生/消除引用时,都伴随着一个加/减操作,对系统性能会有一定影响。

2)可达性分析

目前JVM的主流实现中是通过可达性分析来判定对象是否存活。该方法的基本思想是从“GC roots”对象为起始点,从这些节点开始向下搜索,当某对象未被任意“GC roots”对象搜索到,证明该对象不可用。

可作为GC roots的对象包括以下几种:

1. 虚拟机栈中引用的对象;

2. 方法区中类静态属性引用的对象;

3. 方法区中常量引用的对象;

4. 本地方法栈中JNI引用的对象。

3.3 垃圾回收算法

几种常见的回收算法是:标记清除算法、复制算法、标记整理算法、分代回收算法。

1)标记清除算法

标记清除法分为标记阶段和清除阶段。标记阶段首先通过根节点标记所有从根节点开始的可达对象,因此未被标记的对象就是未被引用的垃圾对象。然后再清除阶段对所有未被标记的对象进行清除。

问题:该方法产生的最大问题是容易产生空间碎片。还有一个是效率问题,标记和清除两个过程的效率都不够高。

2) 复制算法

复制算法的核心是将原内存空间分为两部分,每次只使用一块。垃圾回收时将正在使用的那块内存中的存活对象复制到未被使用的内存块中。之后清除正在使用的内存块中的所有对象。适用于存活对象少,垃圾对象多的情形下,在新生代经常发生。

问题:系统内存折半

注:新生代串行垃圾回收器中采取了这种思想,新生代内存分为Eden、Survivor两部分,新生代中的from/to可视为两块大小相同、地位相等且可角色互换的空间。From/to空间也称为survivor空间,即幸存者空间,用于存放未被回收的对象。

3)标记整理算法

标记压缩算法是一种老年代的回收算法。它在标记清除算法的基础上做了一些优化,将所有存活的对象移动到内存的一端,之后清理边界外的所有空间。既避免的碎片的产生,也不需要将内存分为两块。

4)分代算法

分代算法是指根据对象的特点,将内存区间分为几块,每块内存区间采用不同的回收算法,提高垃圾回收效率。

最常见的就是新生代和老年代的划分。其中新生代每次GC均有大批对象死去,适合采用复制算法;老年代因对象存活率高,适合采用标记整理或标记清除算法。

5) 分区算法

分区算法是将整个堆空间划分成连续的不同小区间,每个小区间独立使用,独立回收。

一般来说,相同条件下堆空间越大,那么一次GC所需要的时间越长,产生的停顿也就越长,为了控制GC产生的停顿时间,将一块大的内存空间分为多个小块,合理地回收若干小区间,可以减小GC产生的停顿时间。

4. 垃圾收集器和内存分配

4.1串行回收器(Serial搜集器)

1)新生代串行回收器

特点:一是使用单线程进行垃圾回收;二是独占式垃圾回收(即应用程序中所有线程都需要暂停)。JVM运行在Client模式下的默认新生代收集器。

2)老年代串行回收器(Serial Old搜集器)

当老年代串行回收器启动时,应用程序可能会停顿较长时间。它可以和多种新生代回收器配合使用,也可以作为CMS回收器的备用。

-XX: +UseSerialGC

新生代、老年代都使用串行回收器

-XX:+UseParNewGC

新生代使用ParNew回收器,老年代使用串行回收器

-XX:+UseparallelGC

新生代使用ParallelGC回收器,老年代使用串行回收器

4.2 并行回收器

1)新生代ParNew回收器

ParNew回收器工作在新生代,它只是简单的将串行回收器并行化,它的回收策略、算法以及参数和新生代串行回收器一样。

2)新生代ParallelGC回收器

新生代ParallelGC回收器也是使用复制算法的回收器,他和ParNew回收器一样都是多线程、独占式的回收器。但是ParallelGC回收器有一个重要特点:它非常关注系统的吞吐量。

常见设置参数:

-XX:+UseParallelGC

新生代使用ParallelGC回收器,老年代使用串行回收器

-XX:+ UseParallelOldGC

新生代使用ParallelGC回收器,老年代使用ParallelOldGC回收器

-XX:+MaxGCpauseMillis

设置最大垃圾收集停顿时间

-XX:+GCTimeRatio

设置系统吞吐量,值为0~100的整数。若值为n,则垃圾收集时间不超过1/(1+n)

-XX:+UseadaptiveSizePolicy

开启自适应GC策略,新生代大小、eden和survivor比例、晋升老年代的年龄等参数会被自动调整

注:系统的停顿时间和吞吐量的大小两个参数是互相矛盾的,通常如果减小一次收集的最大停顿时间,就会同时减小系统吞吐量,增加系统吞吐量则会同时增加一次垃圾回收的最大停顿时间。

3)老年代ParallelOldGC回收器

老年代ParallelOldGC回收器也是一种多线程并发的回收器,也是一种关注吞吐量的回收器,只是表示它是应用于老年代的回收器,并且和ParallelGC新生代回收器搭配使用。

4.3 CMS回收器

CMS(Concurrent Mark sweep, 并发标记清除)回收器主要关注于系统停顿时间,主要步骤有初始标记、并发标记、预清理、重新标记、并发清除和并发重置。其中初始标记和重新标记是独占系统资源的,预清理、并发标记、并发清除和并发重置是可以和用户线程一起执行的。

CMS回收器常见参数设置列表:

-XX:+UseConcMarkSweepGC

启动CMS垃圾回收器

-XX:+ CMSInitiatingOccupancyFraction

指定当老年代空间使用率达到多少时进行一次CMS垃圾回收,默认是68%

-XX:+UseCMSCompactAtFullCollection

设置CMS在垃圾回收完成后是否进行内存碎片整理

4.4G1回收器

从长期来看,G1回收器是为了能取代CMS回收器。从分代上看,G1属于分代垃圾回收器,它会区分年轻代和老年代。而且也使用了分区策略。

G1回收器的特点:

并行性:G1回收期间,可以由多个GC线程同时工作,有效利用多核计算能力;

并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行。因此整个回收期间不会完全阻塞应用程序。

分代GC:G1同时兼顾新生代和老年代,对比其他回收器或工作于新生代,或工作于老年代,这是很大的不同。

空间整理:G1在每次回收之后都会有效的复制对象,减小空间碎片。

可预见性:因为采用分区策略,G1可以只选择部分区域进行内存回收,缩小了回收范围,对于全局停顿得到了较好的控制。

4.4.1 G1的分区与收集过程

G1收集器先将堆进行分区,每次收集的时候只收集其中的几个区域,以此来控制垃圾回收产生的一次停顿时间。

G1收集过程分为4个阶段:新生代GC;并发标记周期;混合收集;如有需要,会进行FullGC。

-XX:+UseG1GC

启动G1回收器

-XX:+ MaxGCPauseMillis

指定最大停顿时间

-XX:+ ParallelGCThreads

设置GC的工作线程数量

4.5 内存分配与回收策略

对象的分配主要是在堆上的分配,主要在新生代的Eden区。对于很长的字符串和数组这类的大对象,直接在老年代分配。另外每个对象有一个对象年龄的计数器,每次MinorGC后存活的对象年龄加1,当年龄增加到一定值(默认15),对象就被晋升到老年代中。

5. Java内存模型与线程

现代计算机结构中在cpu与内存之间都有一层读写速度接近CPU运算速度的高速缓存,以解决I/O操作导致的CPU与内存的速度矛盾。

对于多核系统,这里又引入了一个新的问题:缓存一致性。即每一个CPU都有自己的高速缓存,但他们又共享同一主内存。当多个CPU计算同时涉及到同一主内存区域时,将可能导致各自的缓存数据不一致,产生数据一致性问题。

5.1 主内存与线程工作内存

主内存中存储的变量包括实例字段、静态字段、构成数组的元素等,不包含局部变量和方法参数(线程私有,不共享)。

线程工作内存中保存了被该线程使用的变量在主内存的拷贝副本,线程对变量的所有操作都是在线程自己的工作内存中完成的,不能直接操作主内存中的变量。线程间变量值的传递均需要通过主内存来完成。

5.2 volatile变量

volatile可以说是JVM中最轻量级的同步机制。当一个变量定义为volatile类型后,就具备两种特性:一是保证此变量的改动对于所有线程都是立即可见的;二是禁止指令重排序优化。

当写入一个volatile变量时,JVM会把该线程对应的本地工作内存中的volatile变量值直接刷新到主内存中,同时设定其他线程的工作内存中volatile变量的副本为无效,需重新读入;当读一个volatile变量时,JVM会把该线程本地工作内存的volatile变量设置为无效,从主内存中读入变量值。

可见性是指当一个线程修改了volatile变量的值,那么这个新值对于其他线程来说是立即得知的。由于volatile变量只能保证可见性,那么在不符合以下两个规则的场景下,仍需要通过加锁(synchronized或concurrent包中的原子类)来保证原子性。

1)运算结果并不依赖变量的当前值,或者是能够确保只有一个线程修改变量的值;

2)变量不需要与其他状态变量共同参与不变约束。

如果是多个volatile操作或者是volatile变量++这种符合操作,整个操作整体上并不具备原子性。

Volatile变量的禁止重排序优化功能的实现在于volatile变量在赋值后多了一个lock操作,这个操作相当于一个内存屏障(指重排序时不能把后面的指令重排序到内存屏障之前的位置)。当只有一个CPU访问内存时,并不需要内存屏障;但是多个CPU同时访问主内存且当其中一个CPU在观测另一个CPU的操作时,就需要内存屏障来保证一致性。

一般来讲,volatile同步机制的性能确实要优于锁,但是JVM对锁进行了许多消除和优化,有时候很难量化哪一种性能更优秀。但是从volatile自身来看,都读操作性能消耗和普通变量没什么区别,但写操作会慢一些,因为需要在本地代码中插入内存屏障指令来保证处理器不出现乱序执行。

5.3 final变量

final变量的重排序规则可以保证在final变量为任意线程可见之前,final变量已经被正确初始化了。而final变量的读、写重排序规则可以保证只要final对象正确构造,那么不需要同步也能保证任意线程都能正确看到final变量在初始化之后的值。

5.4 原子性、可见性和有序性

原子性:一种是内存结构直接保证的原子性变量操作,包括read、load、assign、use、store和write。我们基本可认定基本数据类型的访问和读写是原子性的。正常来说,内存模型还提供了lock和unlock来满足更大范围的原子性,更高层次的字节码指令monitorenter和monitorexit则隐式地使用了这两个操作。这两个指令码操作反映到java代码中就是synchronized关键字包围的同步快。

可见性:之前提过,可见性是指当一个线程修改共享变量的值后,其他线程能立即得知这个修改。Volatile的特殊规则保证了修改后的新值能立即同步到主内存,以及每次使用前立即从主内存刷新,那么可以说volatile保证了多线程时操作变量的可见性。

除了volatile之外,synchronized和final也可保证可见性。同步块是因为unlock时必须将变量同步到主内存中;final修饰的变量则在构造器中初始化完后,并没有把this的引用传递出去。

有序性:包括两部分的理解,一是线程内表现为串行执行,则操作是有序的;二是指令重排序和工作内存与主内存的同步延时,则多线程的操作是无序的。

5.5 线程的5种状态

新建:创建后但尚未启动的线程状态。

运行:此状态中的线程有可能正在执行,也有可能在等待CPU为其分配时间片。

等待:分为无限期等待和限期等待,处于无限期等待的线程必须要等到被其他线程显式唤醒,否则不会被CPU分配执行时间。处于有限等待的线程也不会分配CPU时间片,但是在一定时间后会由系统自动唤醒。

阻塞:线程被阻塞状态,即等待着获取一个排他锁。与等待状态的区别是,等待状态是过一段时间或被其他线程唤醒。

结束:已终止的线程状态,表示线程已经执行完毕。

6. 性能监控工具

6.1 linux性能监控命令

6.1.1 top命令

Top命令的输出分为两部分:系统统计信息和进程信息。

系统统计信息的第1行是任务队列信息,它的结果等同于uptime命令。从左至右依次为系统当前时间、系统运行时间、当前登录用户数。Load average表示系统的平均负载,即任务队列的平均长度,三个值分别是1分钟,5分钟,15分钟的平均值

第2行是进程统计信息,分别表示正在运行的进程数、休眠进程数、停止进程数、僵死进程数。

第3行表示CPU统计信息,us表示用户空间CPU占用率,sy表示内核空间CPU占用率。Mem行依次是物理内存总量,已使用物理内存,空闲物理内存,内核缓冲使用量。Swap依次表示交换区总量,空闲交换区大小,缓冲交换区大小。

第二部分是进程信息区,显示系统内各进程的资源使用情况。

6.1.2 vmstat命令

它可以用来统计CPU、内存使用情况、swap使用情况等信息。

命令:vmstat  1  3 (每秒采样1次,共计3次)

Procs

r:等待运行的进程数

b:处在非中断睡眠状态的进程数

Memory

Swap:虚拟内存使用情况,KB

Free:空闲内存,kb

Buff:被用来作为缓存的内存数

IO

Bi:发送到块设备的块数

Bo:从块设备接收到的块数

Swap

Si:从磁盘交换到内存的交换页数量

So:从内存交换到磁盘的交换页数量

System

In:每秒的中断数,包括时钟中断

Cs:每秒上下文切换次数

CPU

Us:用户CPU使用情况

Sy:内核CPU系统使用情况

Id:空闲时间

6.1.3 监控IO的命令——iOStat命令

使用示例:iostat  1  2

6.1.4 多功能诊断器——pidstat命令

6.2 JDK性能监控工具

6.2.1 查看虚拟机运行时信息——jstat命令

6.2.2 查看虚拟机参数——jinfo命令

6.2.3 导出堆到文件——jmap命令

6.2.4 查看线程堆栈——jstack命令

7 分析Java堆

7.1 堆溢出分析

一般来说绝大多数内存溢出的情况都属于堆溢出,原因在于大量的对象占据堆空间,而这些对象都是强引用,导致无法回收。为了缓解堆溢出错误,一方面可以使用-Xms指定更大的堆空间,另一方面也需要分析找出大量占用堆空间的对象,在应用程序上进行合理优化。

7.2 直接内存溢出

Java的Nio中支持通过Java代码向操作系统申请直接内存。一般来说,直接内存的申请速度要比堆内存慢,但是其访问速度要快于堆内存。由于直接内存并没有被JVM完全托管,若使用不当,也会出现直接内存溢出的问题。

7.3 过多线程导致OOM

由于每一个线程都占有一定的系统内存,那么当线程数量过多的时候也有可能导致OOM。因为线程的栈空间是在堆外分配的,如果想让系统支持更多的线程,那么应该使用一个较小的堆空间。

7.4 永久区溢出

永久区存放元数据,如果一个系统定义了太多的类型,那么永久区是有可能溢出的。

7.5 GC效率低下引起的OOM

如果系统的堆空间太小,那么GC所占用的时间就会比较多,并且回收所释放的内存也会比较小。根据GC占用的系统时间以及释放内存的大小,JVM会评估GC的效率,如果效率太低就有可能直接抛出OOM异常。

7.6 MAT分析工具

MAT工具常用的一个功能是在各对象的引用列表中穿梭查看。对于一个给定对象,可以通过MAT找到引用它的对象,即入引用。以及该对象引用的其他对象,即出引用。

浅堆是指对象本身占用的内存,不包括其内部引用对象的大小。一个对象的深堆指能通过该对象访问到的(直接或间接)所有对象的浅堆之和。

8. 锁与并发

8.1 实现线程安全的方式

8.1.1 同步互斥

同步互斥也成为阻塞同步,面临的最大问题是线程阻塞和唤醒所带来的性能问题,尤其是并发环境,吞吐量降低得非常厉害,属于悲观的并发策略。

临界区、互斥量和信号量是主要的互斥实现方式。Java中最基本的互斥同步手段就是synchronized关键字,当synchronized关键字编译后会在同步块前后分别形成monitorenter、monitorexit两个字节码指令。当执行monitorenter指令时,首先尝试获取对象的锁,并把锁的计数器加1,相应地执行monitorexit时锁计数器减1,当减到0时,锁就被释放。

8.1.2 非阻塞同步

非阻塞同步方式是一种乐观的并发策略,许多实现都不需要把线程挂起。例如基于冲突检测的并发策略,其主要思想是先进行数据操作,如果没其他线程争用共享数据,那么直接操作成功;当出现共享数据使用冲突后,采取循环尝试的方式直至操作成功。

此类同步方式最常见的实现是CAS指令(比较并交换),该指令操作三个数,分别是内存位置(V),旧的预期值(A),新值(B)。

8.1.3无同步方案

如果一个方法不涉及到共享数据,那么就不需要任何同步措施去保证正确性,因此有些代码天生就是线程安全的。最常见的方法是使用线程本地存储,将共享数据的可见范围限定在同一个线程之内,这样无需同步也能保证线程之间不出现数据争用的问题。

Java中可以通过Threadlocal类来实现线程本地存储的功能,每个线程的Thread对象都有一个ThreadLocalMap对象,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每个ThreadLocal对象都包含一个独一无二的threadLocalHashCode值,通过这个值就能找到对应的本地线程变量。

8.1 锁的基本概念和实现

锁的基本作用是保护临界区资源不会被多个线程同时访问而受到破坏。通过锁可以实现线程安全。即无论多个线程如何访问目标对象,目标对象的状态也能始终保持一致,线程的行为也是正确的。

8.2 对象头与锁

JVM中每个对象都有一个对象头,用于保存对象的系统信息。对象头中成为MarkWord的部分就是锁实现的关键。一个对象是否占用锁,占用那个锁,这些信息都记录在MarkWord中。

1) 偏向锁

偏向锁是指当某一个锁被线程获取之后,便进入偏向模式,当线程再次请求这个锁时,无需再进行相关的同步操作,从而节省了操作时间。偏向锁在竞争激烈的场合优化效果不是特别如意,因为大量的竞争会导致持有锁的线程不停地切换,锁很难保持在偏向模式。

2) 轻量级锁

轻量级锁在JVM内部,使用一个称为BasicObjectLock的对象实现,这个对象内部由一个BasicLock对象和一个持有该锁的Java对象指针组成。当需要判断某一个线程是否需要持有该对象锁的时候,也只需简单地判断对象头的指针是否在在当前对象的栈地址范围。当轻量级锁加锁失败时,轻量级锁就转变为重量级锁,出现锁膨胀情形。

3)自旋锁

自旋锁可以使线程在没有取得锁的时候不被挂起,而是继续执行空循环(即自旋),在若干空循环之后,如果线程可以获得锁,则继续执行。自旋锁对于锁竞争不是很激烈,锁占用时间很短的并发线程,具有较好的使用效果。如果锁被长时间占用,那么自旋只会白白消耗CPU资源,浪费性能。通常来讲,如果自旋超过限定次数仍然没有获得锁,那么就会使用传统方式将锁挂起。

4)锁消除

锁消除是指JVM在编译的时候,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。JVM在运行时,基于逃逸分析技术,捕获到这些不可能存在竞争却又申请锁的代码段,并消除这些不必要的锁。

8.4 锁优化思路

在程序开发中,应尽可能减小对某个锁的占用时间,以减少线程间互斥的可能。

1)减小锁的粒度

所谓减小锁粒度,是指缩小锁定对象的范围,从而减小锁冲突的可能性,进而提高系统的并发能力。

减小锁粒度是削弱多线程锁竞争的有效手段。ConcurrentHashMap将整个hashMap分成若干段(segment),每个段都是一个子hashMap。在往ConcurrentHashMap中加入数据时,先根据hashcode得到该表项应该被存放在哪个段中,然后对该段加锁,并完成put操作。默认情况下ConcurrentHashMap拥有16个字段。

问题:当系统需要获取全局锁时,消耗资源会比较多,需要同时取得所有段的锁才行。比如ConcurrentHashMap的size()方法。实际中,size()先使用无锁的方式求和,如果失败了才会尝试这种加锁方式。

2)锁分离

锁分离是减小锁粒度的一个特例,是指将一个独占锁分成若干个锁。例如Linkedblockingqueue中使用两把不同的锁分离了take()和put()操作。

3) 锁粗化

一般来讲线程持有锁的时间越短,并发效率越高。但是如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统资源,反而不利于性能的优化。

因此,JVM在遇到一连串连续地对同一个锁进行请求和释放的操作时,会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数。这个操作叫锁的粗化。特别是在循环内的锁,应注意将其粗化。

4)CAS方法

基于锁的同步方式,本质上是阻塞的线程间同步方式,无论是使用信号量,重入锁还是内部锁,都是这种思路。非阻塞同步的方式中最简单的就是以ThreadLocal为代表,每个线程拥有各自独立的变量副本。另外一种非阻塞的同步方式是基于比较交换(Compare And Swap, CAS)算法的无锁并发控制方法。

Concurrent.atomic包下有一组使用无锁算法实现的原子操作类,主要有Atomicinteger,AtomicIntegerArray,AtomicLong,AtomicLongArray和AtomicRegerence等。

在CAS算法中,首先是一个无限循环,这个无限循环用于处理多线程间的冲突处理,即在当前线程受到其他线程影响而更新失败时,会不停尝试直至成功。无锁的操作实际上将多线程并发的冲突处理交由应用层自己解决,提升了系统性能,也增加了系统灵活性。

5)LongAddr

LongAddr在CAS基础上,将热点数据分离成多个单元cell,每个cell独自维护内部的值,当前对象的实际值由所有cell累计合成。

9 Class文件结构

Class文件结构包括:魔数(Class文件特征描述)、小版本号、大版本号、常量池、访问标记、当前类、类方法、类字段、实现的接口、父类等信息。

10. Class装载

10.1 Class加载过程

系统装载Class类型分为加载、连接

和初始化3个步骤。其中连接又分为验证、准备和解析3步。

加载——>连接——>初始化

验证——>准备——>解析

1.     类加载条件

Class只有在必须要使用的时候才会被装载,Java虚拟机不会无条件的装载Class类型。

JVM规定一个类或接口初次使用(主动使用)时必须进行初始化。被动使用不会引起类的初始化,只是加载了类却没有初始化。

主动使用的几种情况:

1.   创建一个类的实例,包括new关键字、反射、克隆、反序列化等;

2.      调用类的静态方法;

3.      当使用类或接口的静态字段(final常量除外);

4.      使用reflect包中的方法反射类的方法时;

5.      初始化子类时,要求初始化父类;

6.      含有main()方法的类;

Final常量由于其不变性,javac在编译时会作优化,将其直接植入目标类,不再使用引用类,这样不用加载引用类。

2.     类加载步骤

1.   通过类的全名,获取类的二进制流;

2.      解析类的二进制流为方法区内的数据结构;

3.      创建java.lang.Class类的实例,表示该类型。

java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据。通过Class类提供的接口,可以访问一个类型的方法,字段等信息。

3. 验证类

验证是连接操作的第一步,目的是保证加载的字节码是合法、合理并符合规范的。包括格式检查、语义检查、字节码验证、符号引用验证等等。

4.     准备

当准备阶段,JVM会为这个类分配相应的内存空间,并设置初始值。当一个类验证通过后,虚拟机就会进入准备阶段。准备阶段是正式为类变量(static修饰的变量)分配内存并设置类变量初始值,这些内存都将在方法区进行分配。这个时候进行内存分配的仅是类变量,不包括实例变量(实例变量将会在对象实例化时随着对象一起分配在堆上)。另外,为类变量设置初始值是设为其数据类型的“零值”。比如 publicstatic int num = 12; 这个时候就会为num变量赋值为0。

如果类中属于常量的字段,那么常量字段也会在准备阶段被附上正确的值,这个赋值属于java虚拟机的行为,属于变量的初始化。

5.     解析

解析阶段的工作是将类、接口、字段和方法的符号引用转为直接引用。符号应用是指字面量的引用,与JVM的内部数据结构和内存分布无关。

所以,解析的目的就是将符号引用转变为直接引用,就是得到类或者字段、方法在内存中的指针或者偏移量。如果直接引用存在,那么系统中肯定存在类、方法或者字段,但只存在符号引用,不能确定系统中一定存在该对象。

1.   初始化

初始化阶段最重要的工作是执行类的初始化方法<clinit>。此方法由编译器自动生成,由类静态成员的赋值语句和static语句块构成。由于在加载一个类之前,JVM总会先加载该类的父类,那么父类的<clinit>总是在子类的<clinit>之前调用。

如果一个类既没有赋值语句,又没有static语句块,那么生成的<clinit>函数就为空,编译器不会为该类插入<clinit>函数。

对于<clinit>函数是带锁线程安全的,JVM内部确保其在多线程环境中是安全的。

10.2ClassLoader工作过程

ClassLoader(类加载器)主要作用于Class装载的加载阶段, ClassLoader通过各种方式将Class信息的二进制流读入系统,然后交给JVM进行连接、初始化操作。

1.  ClassLoader的分类

ClassLoader的层此自顶向下分别为:分为启动类加载器、扩展类加载器、应用类加载器和自定义类加载器四类。当系统使用一个类时,判断一个类是否加载,会自底向上检查一个类是否被加载;当系统加载一个类时,会自顶向下尝试加载一个类直至成功。

使用这种分散的ClassLoader主要是为了使不同层次的类可以由不同的类加载器加载。一般来说,启动类加载器负责加载核心类;扩展类加载器负责加载JAVA_HOME中的类;应用类加载器用户加载用户类;自定义加载器用于加载一些特殊途径的类。

2.     ClassLoader的双亲委托模式

类加载时,系统会判断当前类是否被加载过,如果已经加载则直接返回可用的类,否则就会尝试加载。在加载的时候,先请求双亲处理,如果双亲处理失败,则会自己加载。

判断类死否加载时,应用加载器会顺着双亲路径向上判断,直至启动加载器。但启动加载器不会向下询问,这个委托路线是单向的。

检查类是否加载的过程是单向委托的,但顶层的ClassLoader无法访问底层的ClassLoader所加载的类。通常启动类加载器中的类为系统核心类,包括一些重要的系统接口,而应用加载器中的类为应用类。如此则应用类访问系统类不会有问题,但系统类访问应用类就会有问题,例如会出现无法创建由应用类加载器加载应用实例的问题。

3.     双亲委托模式的补充

通过获取/设置一个线程中的上下文加载器,可以把一个ClassLoader置于一个线程实例中,使得该ClassLoader成为一个相对共享的实例。默认情况下,这个上下文加载器就是应用类加载器,这样可以使得启动类加载器中的代码也能够访问应用类加载器中的类。

4.     突破双亲委托模式

双亲模式的类加载方式是JVM默认的加载方式,通过重载ClassLoader可以修改这一模式。

5.     热替换的实现

Java中通过运用ClassLoader可以实现热替换,即不停止服务来实现修改程序的行为。

相关阅读

Java-正则表达式

序言 正则表达式:字符串处理神器 正则表达式是处理字符串的强大的工具,它不是Java的特性,前端的JavaScript等也有。但是相比于其他

00008第一个java桌面小游戏

我们秉承“快速入门,快速实战”的理念,开发这套系列教程,就是希望朋友们在学习过程中,尽快进入实战环节,尽快介入项目,让大家更有兴趣,更

java多态性浅析

什么是多态? 所谓多态,是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编程时并不确定,而是在程序运行

SEO利器-Google GSA虚拟机版本

在所有的SEO工具中,能够被称为利器的工具不多,但Google GSA虚拟机版本绝对算是一个。去年我介绍了《利用Google Search Appliance

随机函数JAVA

首先第一步是创建Java 接下来开始我们随机函数的代码块 package first; import java.util.StringTokenizer; public class First

分享到:

栏目导航

推荐阅读

热门阅读