《实战Java虚拟机:JVM故障诊断与性能优化(第2版)》笔记
第一章 Java虚拟机基本结构
- 认识Java虚拟机堆
- 了解有关栈的使用
- 了解存放类型描述的永久区和元数据区
1.1 Java虚拟机架构

- 类加载子系统:从文件系统或者网络中加载类信息
- 方法区:储存类信息、和
字符串字面量和数字常量,是线程共享 - Java堆:储存Java实例,是线程共享的
- 垃圾回收:可以对方法区、Java堆和直接内存进行回收
- Java栈: 栈在线程创建时创建,保存着栈信息、局部变量和方法参数,是线程私有的
- 本地方法栈:本地方法栈和Java栈非常类似,最大的不同在于Java栈用于Java方法的调用,而本地方法栈则用于本地方法的调用
- PC寄存器:用于储存当前方法,如果当前方法不是本地方法,PC寄存器就会指向当前正在被执行的指令。如果当前方法是本地方法,那么PC寄存器的值就是undefined
- 执行引擎:执行虚拟机的字节码
字符串字面量:字符串字面量是指使用双引号“”括起来的的字符序列,例如:“Hello World”。
1.2 辨清Java堆
Java堆结构根据垃圾回收机制来设计,最常见结构是 新生代和老年代 。
新生代有可能分为eden、from和to区域(from和to一起就是survivor区),它们
是两块大小相等、可以互换角色的内存空间。在绝大多数情况下,对象首先在eden区分配,在一次新生代回收后,如果对象还存活,则会进入from和to区域,之后,每经过一次新生代回收,对象如果存活,它的年龄就会加1。当对象的年龄达到一定条件后,就会被认为是老年对象,从而进入老年代。

eden:eden是“伊甸园”的意思。根据圣经的记载,亚当和夏娃就住在伊甸园,那也是人类开始居住的地方。这里沿用伊甸园的名字也就是这个意思。
1.3 出入Java栈
每一次函数调用,都会有一个对应的栈帧被压入Java栈,每一次函数调用结束,都会有一个栈帧被弹出Java栈。当请求的栈深度大于最大可用栈深度时,系统就会抛出* StackOverflowError* 栈溢出错误。
栈桢
- 局部变量表:当函数调用结束后,函数栈帧销毁,局部变量表也会随之销毁。在相同的栈容量下,局部变量少的函数可以支持更深层次的函数调用
- 操作数栈:用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 栈数据区:支持常量池解析、正常方法返回和异常处理

1.4 识别方法区
方法区用于存系统的类信息,比如类的字段、方法、常量池等。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误。
在JDK 1.6、JDK 1.7中,方法区可以理解为永久区(Perm);
在JDK 1.8中,永久区已经被彻底移除。取而代之的是元数据区
第二章 垃圾回收的概念与算法
2.1 垃圾回收算法
- 引用计数法:对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1,当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,则对象A就不可能再被使用。有以下缺点:
-无法处理循环引用。两个对象相互引用,则不会被回收。
-引用计算器要求在每次引用产生和消除的时候,伴随一个加法操作和一个减法操作,对系统性能会有一定的影响。 - 标记清除法:首先通过根节点标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。标记清除法的题是可能产生空间碎片。
- 复制算法:将原有的内存空间分为两块,每次只使用其中一块,在进行垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后清除正在使用的内存块中的所有对象。缺点是系统内存折半。复制算法比较适合新生代,因为在新生代垃圾对象通常会多于存活对象,复制算法的效果会比较好。

- 标记压缩法:在标记清除法的基础上,它并不只是简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。标记压缩法是一种老年代的回收算法.
- 分代算法:将内存区间根据对象的特点分成几块,根据每块内存区间的特点使用不同的回收算法,以提高垃圾回收的效率。生代回收的频率很高,但是每次回收的耗时很短,而老年代回收的频率比较低,但是会消耗更多的时间。为了支持高频率的新生代回收,虚拟机可能使用一种叫作卡表(Card Table)的数据结构。
- 分区算法:将整个堆空间划分成连续的不同小区间,每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收小区间的数量。根据目标停顿时间,每次合理地回收若干个小区间,而不是回收整个堆空间,从而减少一次GC所产生的停顿。

卡表:卡表为一个比特位集合,每一个比特位可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用这样在新生代GC时,可以不用花大量时间扫描所有的老年代对象来确定每一个对象的引用关系,可以先扫描卡表,只有当卡表的标
记位为1时,才需要扫描给定区域的老年代对象,而卡表位为0的老年代对象,一定不含有新生代对象的引用。
2.2 如何判断对象是否回收
即从根节点开始是否可以访问这个对象,如果可以,则说明当前对象正在被使
用,如果从所有的根节点开始都无法访问到某个对象,说明该对象已经不再使用了,一般来说,该对象需要被回收。
可触及性包含以下3种状态:
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的: 对象的所有引用都被释放,但是对象有可能在finalize()函数中复活。
- 不可触及的: 对象的finalize()函数被调用,并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为finalize()函数只会被调用一次。
Java中提供了4个级别的引用:
- 强引用:强引用就是程序中一般使用的引用类型,强引用的对象是可触及的,不会被回收。
- 软引用:如果一个对象只持有软引用,那么当堆空间不足时,就会被回收。
- 弱引用:在系统GC时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。
- 虚引用:一个持有虚引用的对象,和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
2.3 垃圾回收时的停顿(Stop-The-World)
停顿的目的是终止所有应用线程的执行,只有这样系统中才不会有新的垃圾产生,同时停顿保证了系统状态在某一个瞬间的一致性,也有益于垃圾回收器更好地标记垃圾对象。
第三章 垃圾收集器和内存分配
3.1 串行回收器
- 新生代串行回收器:适合单CPU处理器等硬件平台不是特别优越的情况,它的性能表现可以超过并行回收器和并发回收器,使用复制算法。
- 它仅仅使用单线程进行垃圾回收
- 独占式的垃圾回收方式,在串行回收器进行垃圾回收时,会有“Stop-
The-World”。
- 老年代串行回收器:与新生代串行回收器的区别在于使用的是标记压缩算法。
3.2 并行回收器
并行回收器在串行回收器的基础上做了改进,它使用多个线程同时进行垃圾回收。对于并行能力强的计算机,可以有效减少垃圾回收所需的实际时间。
- 新生代ParNew回收器:与新生代串行回收器的区别在于使用的是,是进行垃圾回收时时是并行的,在并发能力比较强的CPU上,它产生的停顿时间要短于串行回收器。
- 新生代ParallelGC回收器:与ParNew回收器的区别在于,非常关注系统的吞吐量,可是设置吞吐量的大小,系统用于垃圾回收的时间不超过多少。
- 老年代ParallelOldGC回收:与新生代ParallelGC回收器的区别在于,一是用于老年代,二是使用标记压缩算法。
3.3 CMS回收器(JDK 8及之前的版本)
回收流程如下:
- 初始标记(STW):标记根对象
- 并发标记:标记所有对象
- 预清理:清理前准备及控制停顿时间
- 重新标记(STW):标记并发期间生成的需要回收的对象
- 并发清理:清理垃圾
- 并发重置:重新初始化CMS数据结构和数据

- 使用标记清除法,-XX:+UseCMSCompactAtFullCollection参数可以使CMS在垃圾收集完成后,进行一次内存碎片整理,内存碎片的整理不是并发进行的。
3.4 G1回收器(JDK 9及之后版本的默认回收器)
//todo
3.5 有关对象内存分配和回收的一些细节
- 并行GC前额外触发的新生代GC:对于并行回收器的Full GC,在每一次Full GC之前都会伴随一次新生代GC。目的是先将新生代进行一次回收,避免将所有回收工作同时交给一次Full GC进行,从而尽可能地缩短一次停顿时间。
- 大对象进入老年代:如果对象体积很大,新生代无论eden区还是survivor区都无法容纳这个对象,自然这个对象无法存放在新生代,也非常有可能被直接晋升到老年代。
- 栈上分配:对于那些线程私有的对象(这里指不可能被其他线程访问的对象),
可以将它们打散分配在栈上,而不是分配在堆上。 - 在TLAB(线程本地分配缓存)上分配对象:作用是为了加速对象分配。由于对象一般会分配在堆上,而堆是全局共享的。在同一时间,可能会有多个线
程在堆上申请空间(也就是生成对象)。因此,每一次对象分配都必须进行同步,而在竞激烈的场合分配的效率又会进一步下降。
对象分配流程如下:

第四章 分析Java堆
4.1 内存溢出-OOM(OutOfMemory)
- 堆溢出:大量对象占据了堆空间,而这些对象都持有强引用,无法回收,当对象大小之和大于由Xmx参数指定的堆空间大小时,溢出错误就自然而然地发生了。
- 直接内存溢出:Java的NIO(New I/O)中,支持直接内存使用,也就是通过
Java代码获得一块堆外的内存空间,这块空间是直接向操作系统申请的。直接内存的申请速度一般要比堆内存慢,但是其访问速度要快于。 - 过多线程导致OOM:由于每一个线程的开启都要占用系统内存,因此当线程数量太多时,也有可能导致OOM。由于线程的栈空间也是在堆外分配的,因此和直接内存非常相似。
- 元空间溢出:如果一个系统中有太多的类型,那么元空间是有可能溢出的。
4.2 String对象的特点
- 不变性:不变性是指String对象一旦生成,则不能再对它进行改变。可以提高多线程访问的性能。因为对象不可变,对于所有线程都是只读的。
- 针对常量池的优化:当两个String对象拥有相同的值时,它们只引用常量池中的同一个副本。

String.intern()返回字符串在常量池中的引用
- 类的final定义:作为final类的String对象在系统中不可能有任何子类,这是对系统安全性的保护。
内存泄露:由于疏忽或错误造成程序未能释放已经不再使用的内存。
- String常量池的位置:
- 在JDK 1.6之前,这属于永久区的一部分。
- JDK 1.7以后,它被移到了堆中进行管理。
第五章 锁与并发
5.1 对象头和锁
每个对象都有一个对象头,用于保存对象的系统信息,对象头中有一个称为Mark Word的部分,它是一个多功能的数据区,可以存放对象的哈希值、对象年龄、锁的指针等信息。一个对象是否占用锁、占用哪个锁,就记录在这个Mark Word中。
5.2 锁在Java虚拟机中的实现和优化
- 偏向锁:只有一个线程持有过这个锁,当线程再次请求这个锁时,无须再进行相关的同步操作,从而节省了操作时间*。偏向锁在锁竞争激烈的场合没有太强的优化效果,反而有可能降低系统性能,可以尝试使用-XX:-UseBiasedLocking参数禁用偏向锁。
- 轻量级锁:有多个线程持有过这个锁,但加锁的时间是错开的(也就是没有竞争)。
- 重量级锁:有多个线程同时竞争同一个锁。
- 锁膨胀:偏向锁 -> 轻量级锁 -> 重量级锁
- 自旋锁:线程在没有取得锁时不被挂起,而去执行一个空循环(即所谓的自旋),在若干个空循环后,线程如果可以获得锁,则继续执行。
- 锁消除:锁消除是Java虚拟机在编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。比如使用StringBuffer、Vector等。
5.3 锁在应用层的优化思路
- 减少锁持有的时间。
- 减少锁粒度,指缩小锁定对象的范围,从而减小锁冲突的可能性。如 ConcurrentHashMap,将整个HasmMap分成若个段(Segment),每个段都
是一个子HashMap。如果需要在ConcurrentHashMap中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个
段中,然后对该段加锁,并完成put()操作。
- 锁分离:将一个独占锁分成多个锁。减小锁粒度的一个特例。如LinkedBlockingQueue,在其实现中,take()和put()分别实现了从队列中前端取得数据和往队列后端中增加数据的功能,从理论上说,两者并不冲突,所以用两把不同的锁分离了take()和put()操作。
笔记来源:《实战Java虚拟机:JVM故障诊断与性能优化(第2版)》

浙公网安备 33010602011771号