Java工程师的进阶之路 JVM篇(二)

764 阅读18分钟

白菜Java自习室 涵盖核心知识

Java工程师的进阶之路 JVM篇(一)
Java工程师的进阶之路 JVM篇(二)
Java工程师的进阶之路 JVM篇(三)

1. Java 内存模型

Java 内存模型 (Java Memory Model,JMM) 是 Java 虚拟机规范定义的,用来屏蔽掉 Java 程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现 Java 程序在各种不同的平台上都能达到内存访问的一致性。可以避免像 c++ 等直接使用物理硬件和操作系统的内存模型在不同操作系统和硬件平台下表现不同,比如有些 c/c++ 程序可能在 windows 平台运行正常,而在 linux 平台却运行有问题。

1.1 主内存和工作内存之间的交互

操作作用对象解释
lock主内存把一个变量标识为一条线程独占的状态
unlock主内存把一个处于锁定状态的变量释放出来,释放后才可被其他线程锁定
read主内存把一个变量的值从主内存传输到线程工作内存中,以便 load 操作使用
load工作内存把 read 操作从主内存中得到的变量值放入工作内存中
use工作内存把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行这个操作
assign工作内存把一个从执行引擎接收到的值赋接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store工作内存把工作内存中的一个变量的值传送到主内存中,以便 write 操作
write工作内存把 store 操作从工作内存中得到的变量的值放入主内存的变量中

1.2 对于 volatile 型变量的特殊规则

关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。

一个变量被定义为 volatile 的特性:

  • 保证此变量对所有线程的可见性。但是操作并非原子操作,并发情况下不安全。

如果不符合 运算结果并不依赖变量当前值,或者能够确保只有单一的线程修改变量的值 和 变量不需要与其他的状态变量共同参与不变约束 就要通过加锁(使用 synchronize 或 java.util.concurrent 中的原子类)来保证原子性。

  • 禁止指令重排序优化。

通过插入内存屏障保证一致性。

1.3 对于 long 和 double 型变量的特殊规则

Java 要求对于主内存和工作内存之间的八个操作都是原子性的,但是对于 64 位的数据类型,有一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。这就是 long 和 double 的非原子性协定。

1.4 原子性、可见性与有序性

  • 原子性 (Atomicity)

由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write。大致可以认为基本数据类型的操作是原子性的。同时 lock 和 unlock 可以保证更大范围操作的原子性。而 synchronize 同步块操作的原子性是用更高层次的字节码指令 monitorenter 和 monitorexit 来隐式操作的。

  • 可见性 (Visibility)

是指当一个线程修改了共享变量的值,其他线程也能够立即得知这个通知。主要操作细节就是修改值后将值同步至主内存(volatile 值使用前都会从主内存刷新),除了 volatile 还有 synchronize 和 final 可以保证可见性。同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步会主内存中( store、write 操作)”这条规则获得。而 final 可见性是指:被 final 修饰的字段在构造器中一旦完成,并且构造器没有把 “this” 的引用传递出去( this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见 final 字段的值。

  • 有序性 (Ordering)

如果在被线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句指“线程内表现为串行的语义”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。Java 语言通过 volatile 和 synchronize 两个关键字来保证线程之间操作的有序性。volatile 自身就禁止指令重排,而 synchronize 则是由“一个变量在同一时刻指允许一条线程对其进行 lock 操作”这条规则获得,这条规则决定了持有同一个锁的两个同步块只能串行的进入。

1.5 先行发生原则

也就是 happens-before 原则。这个原则是判断数据是否存在竞争、线程是否安全的主要依据。先行发生是 Java 内存模型中定义的两项操作之间的偏序关系。

天然的先行发生关系

规则解释
程序次序规则在一个线程内,代码按照书写的控制流顺序执行
管程锁定规则一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
volatile 变量规则volatile 变量的写操作先行发生于后面对这个变量的读操作
线程启动规则Thread 对象的 start() 方法先行发生于此线程的每一个动作
线程终止规则线程中所有的操作都先行发生于对此线程的终止检测(通过 Thread.join() 方法结束、 Thread.isAlive() 的返回值检测)
线程中断规则对线程 interrupt() 方法调用优先发生于被中断线程的代码检测到中断事件的发生(通过 Thread.interrupted() 方法检测)
对象终结规则一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
传递性如果操作 A 先于 操作 B 发生,操作 B 先于 操作 C 发生,那么操作 A 先于 操作 C

2. Java 内存分配

Java堆的内存划分如图所示,分别为年轻代、Old Memory(老年代)、Perm(永久代)。其中在 Jdk1.8 中,永久代被移除,使用 MetaSpace 代替。

  • 新生代:
  1. 使用复制清除算法(Copinng算法),原因是年轻代每次GC都要回收大部分对象。新生代里面分成一份较大的 Eden 空间和两份较小的 Survivor 空间。每次只使用 Eden 和其中一块 Survivor 空间,然后垃圾回收的时候,把存活对象放到未使用的 Survivor(划分出from、to)空间中,清空 Eden 和刚才使用过的 Survivor 空间。
  2. 分为 Eden、Survivor From、Survivor To,比例默认为8:1:1
  3. 内存不足时发生 Minor GC
  • 老年代:
  1. 采用标记-整理算法(mark-compact),原因是老年代每次 GC 只会回收少部分对象。
  • Perm:用来存储类的元数据,也就是方法区。
  1. Perm的废除:在 jdk1.8 中,Perm 被替换成 MetaSpace,MetaSpace 存放在本地内存中。原因是永久代进场内存不够用,或者发生内存泄漏。
  2. MetaSpace(元空间):元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

3. Java 垃圾回收

程序计数器、虚拟机栈、本地方法栈 3 个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存。

3.1 判断对象已死

3.1.1 引用计数法

给对象添加一个引用计数器。但是难以解决循环引用问题。如果不下小心直接把 Obj1-reference 和 Obj2-reference 置 null。则在 Java 堆当中的两块内存依然保持着互相引用无法回收。

3.1.2 可达性分析法

通过一系列的 ‘GC Roots’ 的对象作为起始点,从这些节点出发所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连的时候说明对象不可用。

可作为 GC Roots 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象

3.1.3 再谈引用

下面四种引用强度一次逐渐减弱

  • 强引用

类似于 Object obj = new Object(); 创建的,只要强引用在就不回收。

  • 软引用

SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。

  • 弱引用

WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。

  • 虚引用

PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

3.1.4 生存还是死亡

即使在可达性分析算法中不可达的对象,也并非是“facebook”的,这时候它们暂时出于“缓刑”阶段,一个对象的真正死亡至少要经历两次标记过程:如果对象在进行中可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象竟会放置在一个叫做 F-Queue 的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会出发这个方法,并不承诺或等待他运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象简历关联即可。

finalize() 方法只会被系统自动调用一次。

3.1.5 回收方法区

在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而永久代的垃圾收集效率远低于此。 永久代垃圾回收主要两部分内容:废弃的常量和无用的类。

判断废弃常量:一般是判断没有该常量的引用。

判断无用的类:要以下三个条件都满足

  • 该类所有的实例都已经回收,也就是 Java 堆中不存在该类的任何实例
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有任何地方呗引用,无法在任何地方通过反射访问该类的方法

3.2 垃圾回收算法

3.2.1 Mark-Sweep(标记-清除算法):

  • 思想:标记清除算法分为两个阶段,标记阶段和清除阶段。标记阶段任务是标记出所有需要回收的对象,清除阶段就是清除被标记对象的空间。
  • 优缺点:实现简单,容易产生内存碎片

3.2.2 Copying(复制清除算法):

  • 思想:将可用内存划分为大小相等的两块,每次只使用其中的一块。当进行垃圾回收的时候了,把其中存活对象全部复制到另外一块中,然后把已使用的内存空间一次清空掉。
  • 优缺点:不容易产生内存碎片;可用内存空间少;存活对象多的话,效率低下。

3.2.3 Mark-Compact(标记-整理算法):

  • 思想:先标记存活对象,然后把存活对象向一边移动,然后清理掉端边界以外的内存。
  • 优缺点:不容易产生内存碎片;内存利用率高;存活对象多并且分散的时候,移动次数多,效率低下

3.2.4 分代收集算法:(目前大部分JVM的垃圾收集器所采用的算法):

  • 思想:把堆分成新生代和老年代。(永久代指的是方法区)
  1. 因为新生代每次垃圾回收都要回收大部分对象,所以新生代采用 Copying 算法。新生代里面分成一份较大的 Eden 空间和两份较小的 Survivor 空间。每次只使用 Eden 和其中一块 Survivor 空间,然后垃圾回收的时候,把存活对象放到未使用的 Survivor(划分出from、to)空间中,清空 Eden 和刚才使用过的 Survivor 空间。
  2. 由于老年代每次只回收少量的对象,因此采用 mark-compact 算法。
  3. 在堆区外有一个永久代。对永久代的回收主要是无效的类和常量

3.3 垃圾回收器

3.3.1 Serial 收集器

这是一个单线程收集器。意味着它只会使用一个 CPU 或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。

3.3.2 ParNew 收集器

可以认为是 Serial 收集器的多线程版本。

并行:Parallel

指多条垃圾收集线程并行工作,此时用户线程处于等待状态

并发:Concurrent

指用户线程和垃圾回收线程同时执行(不一定是并行,有可能是交叉执行),用户进程在运行,而垃圾回收线程在另一个 CPU 上运行。

3.3.3 Parallel Scavenge 收集器

这是一个新生代收集器,也是使用复制算法实现,同时也是并行的多线程收集器。

CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。

作为一个吞吐量优先的收集器,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整停顿时间。这就是 GC 的自适应调整策略(GC Ergonomics)。

3.3.4 Serial Old 收集器

收集器的老年代版本,单线程,使用 标记 —— 整理。

3.3.5 Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多线程,使用 标记 —— 整理

3.3.6 CMS 收集器

CMS (Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。基于 标记 —— 清除 算法实现。

缺点:对 CPU 资源敏感、无法收集浮动垃圾、标记 —— 清除 算法带来的空间碎片

运作步骤:

  1. 初始标记 (CMS initial mark):标记 GC Roots 能直接关联到的对象
  2. 并发标记 (CMS concurrent mark):进行 GC Roots Tracing
  3. 重新标记 (CMS remark):修正并发标记期间的变动部分
  4. 并发清除 (CMS concurrent sweep)

3.3.7 G1 收集器

面向服务端的垃圾回收器。

优点:并行与并发、分代收集、空间整合、可预测停顿。

运作步骤:

  1. 初始标记 (Initial Marking)
  2. 并发标记 (Concurrent Marking)
  3. 最终标记 (Final Marking)
  4. 筛选回收 (Live Data Counting and Evacuation)

4. JVM 优化

  1. 一般来说,当 survivor 区不够大或者占用量达到50%,就会把一些对象放到老年区。通过设置合理的 eden 区,survivor 区及使用率,可以将年轻对象保存在年轻代,从而避免 full GC,使用 -Xmn 设置年轻代的大小

  2. 对于占用内存比较多的大对象,一般会选择在老年代分配内存。如果在年轻代给大对象分配内存,年轻代内存不够了,就要在 eden 区移动大量对象到老年代,然后这些移动的对象可能很快消亡,因此导致 full GC。通过设置参数:-XX:PetenureSizeThreshold=1000000,单位为 B,标明对象大小超过 1M 时,在老年代(tenured)分配内存空间。

  3. 一般情况下,年轻对象放在 eden 区,当第一次 GC 后,如果对象还存活,放到 survivor 区,此后,每 GC 一次,年龄增加1,当对象的年龄达到阈值,就被放到 tenured 老年区。这个阈值可以同构 -XX:MaxTenuringThreshold 设置。如果想让对象留在年轻代,可以设置比较大的阈值。

  4. 设置最小堆和最大堆:-Xmx 和 -Xms 稳定的堆大小堆垃圾回收是有利的,获得一个稳定的堆大小的方法是设置 -Xms 和 -Xmx 的值一样,即最大堆和最小堆一样,如果这样子设置,系统在运行时堆大小理论上是恒定的,稳定的堆空间可以减少 GC 次数,因此,很多服务端都会将这两个参数设置为一样的数值。稳定的堆大小虽然减少 GC 次数,但是增加每次 GC 的时间,因为每次 GC 要把堆的大小维持在一个区间内。

  5. 一个不稳定的堆并非毫无用处。在系统不需要使用大内存的时候,压缩堆空间,使得 GC 每次应对一个较小的堆空间,加快单次 GC 次数。基于这种考虑,JVM 提供两个参数,用于压缩和扩展堆空间。

(1)-XX:MinHeapFreeRatio 参数用于设置堆空间的最小空闲比率。默认值是40,当堆空间的空闲内存比率小于40,JVM便会扩展堆空间
(2)-XX:MaxHeapFreeRatio 参数用于设置堆空间的最大空闲比率。默认值是70, 当堆空间的空闲内存比率大于70,JVM便会压缩堆空间
(3)当-Xmx和-Xmx相等时,上面两个参数无效

  1. 通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器。

(1)-XX:+UseParallelGC:年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。
(2)-XX:+UseParallelOldGC:设置老年代使用并行垃圾回收收集器。

  1. 尝试使用大的内存分页:使用大的内存分页增加CPU的内存寻址能力,从而系统的性能。-XX:+LargePageSizeInBytes 设置内存页的大小

  2. 使用非占用的垃圾收集器。-XX:+UseConcMarkSweepGC老年代使用CMS收集器降低停顿。

  3. -XXSurvivorRatio=3,表示年轻代中的分配比率:survivor:eden = 2:3

  4. JVM 性能调优的工具:

(1)jps(Java Process Status):输出 JVM 中运行的进程状态信息(现在一般使用jconsole)
(2)jstack:查看 java 进程内线程的堆栈信息。
(3)jmap:用于生成堆转存快照
(4)jhat:用于分析 jmap 生成的堆转存快照(一般不推荐使用,而是使用 Ecplise Memory Analyzer)
(5)jstat是 JVM 统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。
(6)VisualVM:故障处理工具

Java工程师的进阶之路 JVM篇(一)
Java工程师的进阶之路 JVM篇(二)
Java工程师的进阶之路 JVM篇(三)