深入理解JVM


Java运行时数据区域

关于Java程序的内存模型,一定是被说烂了的,学过计算机组成之后就能在这里看到一些计算机组成的影子。

其中方法区和堆(heap)是线程共享的,其余三个:虚拟机栈,本地方法栈,程序计数器是线程隔离的。

太明显了,所以直接总结和描述下几个区域的作用:

一、程序计数器

程序计数器简单来说,就是指向当前正在执行的程序字节码的标志,通过计数器来解决程序各种结构的正常运行。

Key:为什么程序计数器是线程隔离的?

Ans:因为Java的多线程是通过线程轮流切换且分配处理器时间实现的,所以对于每一条线程,该线程的程序计数器必须指向当前线程中程序正在执行的字节码,从而正确执行当前线程的指令。线程之间互不影响,所以程序计数器是线程私有的。

二、Java虚拟机栈

虚拟机栈我想用图的方式描述更加的形象具体:

局部变量表中存放了基本数据类型、对象引用和returnAddress类型。

虚拟机栈的生命周期从线程创建到线程被销毁,同样虚拟机栈是线程私有的。

Key:为什么虚拟机栈是线程私有的?

Ans:每个线程中的方法互相独立,不会共享数据,所以它是线程私有的。

三、本地方法栈

本地方法栈和Java虚拟机栈相似,他们直接的区别是:

虚拟机栈用来执行的是Java方法,而本地方法栈用来执行Native方法。

四、Java堆(Java heap)

Java堆是Java虚拟机管理的内存占用最大的一块。这里也是垃圾收集器的主要管理区域,所以也被称做GC堆。

Java堆是被所有线程共享的一块内存区域,此处在虚拟机启动时被创建。

这个区域的唯一作用就是用来存放对象实例。

从内存回收的角度上讲,如今的收集器基本都是分代回收算法,所以Java堆中也可以被细分为新生代老年代

但不论如何细分,都和存放内容无关,无论什么区域,存储的都是对象实例。

Java堆可以是物理上不连续的,只要逻辑上连续就可以了。

当堆中没有足够的空间来完成实例分配,而且无法再扩展时,就会抛出OutOfMeoryError异常。

五、方法区

方法区则与Java堆相同,是线程共享的内存区域。

这里用于存储虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等。

方法区也被称为Non-Heap。

special:运行时常量池

这里是方法区的一部分,各种编译时产生的字面值和符号引用被加载到运行时常量池中。

常量池同时具有动态性,运行期间也可以将新的常量放入常量池中,比如说String类中的intern()方法。

Key:intern()方法该怎么用?

Ans:在调用”ab”.intern()方法的时候会返回”ab”,但是这个方法会首先检查字符串池中是否有”ab”这个字符串,如果存在则返回这个字符串的引用,否则就将这个字符串添加到字符串池中,然会返回这个字符串的引用。

这个用法十分奇特,有空可以将一下这个字符串对象和字符串引用的细节。

GC篇

GC主要发生在Java堆和方法区,因为其为线程共享。

其他三个部分由于线程私有,在其线程结束后就被销毁,所以不需要对其他三个区进行GC。

判断一个对象可以回收的方法

1. 引用计数法

为每个对象添加一个引用计数器,增加一个引用时计数器+1,减少一个引用计数器-1,当计数器为0的时候,这个对象就可以回收。

可能会出现循环引用的情况,即a是b的引用,同时b是a的引用,所以不使用该方法。

2. 分析可达性算法

从GC Roots开始作为起点,开始进行搜索,能够到达的对象都是存活的,不能到达的对象都是需要进行回收的。

GCRoot包含的内容有:

  1. 虚拟机栈中局部变量表中引用的对象
  2. 本地方法栈中JNI中引用的对象
  3. 方法区中类静态属性引用的对象
  4. 方法区中的常量引用的对象

算法中可能存在的问题:

多线程环境可能会存在某种可能性,其他线程可能会更新已经访问过对象中的引用,可达性分析线程没有同步到最新的内容,可能就会产生漏报和误报,误报就可能产生一个对象在某个位置依然被引用,但是已经被回收了。

解决方法

在Java虚拟机中,每次启动垃圾回收,都会暂停其他所有线程的任务,直到垃圾回收完成,这有点像DIO的the world。必须要等待垃圾回收线程完成工作才能继续其他线程。这个时间就叫GC PAUSE。

所以JVM中设计了一个安全点(safepoint),发出GC请求后,等待所有线程都到达安全点,才会开始GC。

3. 方法区的回收

方法区主要存放永久代对象,永久代对象回收率很低。

主要是对常量池的回收和对类的卸载。

卸载类的条件很多,需要满足以下3个条件,而且满足了也不一定会被回收。

  1. 该类所有的实例都被回收,这时候堆中没有任何该类的实例对象。
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的Class对象没有被任何地方引用,所有地方都无法通过反射访问该类方法。

4. finalize()

类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。

引用类型

1.强引用

被强引用的对象不会被回收。

Object obj = new Object();

2.软引用

被软引用的对象只有在内存不够的时候才会被回收。

Object obj = new Object();
SoftReference<Object> sf = newSoftReference<Object>(obj);
obj = null;

3. 弱引用

若引用的对象一定被回收,只能存活到下次GC之前。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

4. 虚引用

又被称为幻影引用,有没有虚引用不会对这个对象的生存时间产生影响,也无法通过虚应用得到一个对象。

唯一的目的是在这个对象被回收的时候收到一个系统通知。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null; 

垃圾收集算法

1.标记-清除

S1. 标记阶段,在标记阶段,程序检查每个对象是否为活动对象,如果是活动对象,在对象头部打上标记,

S2. 清除阶段,进行对象回收并取消标志位。同时判断回收后分块和前一个空闲分块是否连续,如果连续会合并这两个分块。回收工作就是把对象作为分块,并且将这些分块连接到‘空闲链表’的单向链表上。分配时只需要遍历链表就可以找到分块。

在分配时,程序会搜索空闲链表寻找大于等于新对象size的块,当找到相同大小的块直接返回,找到大于的块则会将其分割为两部分,一部分拿来用,另一部分返回空闲链表。

不足

1.标记清除效率低

  1. 产生大量碎片化内存,大对象可能无法正常分配。

2. 标记-整理

让存活的对象向一端移动,直接清理边界外的内存。

虽然不会产生碎片,但是对象内存的移动,处理效率太低。

3.复制

把内存分割为大小相同的两份,使用其中的一份。当这份内存用尽时,将当前存活的对象复制到另一份内存中,然后清理原来那一份内存。

吞吐量比较大。

最大的不足就是只使用了内存的一半。

3. 分代收集

根据对象存活周期来进行划分,一般划分为新生代和老年代。

新生代一般使用复制算法

老年代一般使用标记清除或者标记整理

垃圾收集器

垃圾收集器的不同种类其实可以配合使用,常用的垃圾收集器有7种。

垃圾收集器也有不同的特性:

单线程与多线程:单线程就是GC线程只有一个,多线程就是有多个。

串行和并行:串行是指在运行GC的时候需要暂停用户程序(the world!),并行指的的是GC和用户程序同步进行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

1. Serial收集器

直译为串行收集器,自然以串行的方式收集。

新生代采用复制算法,老年代采用标记-整理算法

同时也是一个单线程收集器,只有一个GC线程。

优点是简单高效,单个CPU内没有线程交互的开销,拥有最高的单线程收集效率。

其为Client场景下默认的新生代收集器,因为在该场景下内存一般不大。

2. ParNew收集器

其为Serial收集器的的多线程版本。

其为Server场景下默认的新生代收集器,除了Serial收集器外,只有ParNew收集器能与CMS收集器共用。

3. Parallel Scavenge收集器

其为多线程收集器。

新生代采用复制算法,老年代采用标记-整理算法。

其他收集器的目标是缩短GC停顿时间,而这个收集器的目标是达到一个可控的吞吐量,所以也被称为吞吐量收集器。

此处吞吐量指CPU用于运行用户程序占总程序运行时间的比值。
短的停顿时间适合用户交互型程序。高吞吐量则适合后台运算型程序。

停顿时间的缩短是以牺牲吞吐量和新生代空间来换取的,新生代空间变小,垃圾回收变得频繁,吞吐量下降。

4. Serial Old收集器

是Serial收集器的老年代版本,在Client场景下的虚拟机使用。如果在Server场景下,其有两大用途:

  1. 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  2. 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

5. Parallel Old 收集器

是Parallel Scavenge收集器的老年代版本。

CMS收集器

CMS(Concurrent Mark Sweep),多线程标记清除算法。

流程如下:

  1. 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  2. 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  3. 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  4. 并发清除:不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

  1. 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  2. 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  3. 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

7. G1收集器

(Garbage-first),是一个面向服务端应用的垃圾收集器,在多CPU和大内存场景下有较好的性能。

整体上采用标记-整理算法,局部采用复制算法(两个Region之间)。运行期间不会产生空间碎片。

其他收集器都是针对新生代或者老年代进行手机,而G1收集器直接对新生代老年代一起回收。

G1把堆分割成多个大小相同的对立区域(Region),新生代和老年代不在物理隔离。

Region将一整块内存空间分割成多个小空间,使得每个小空间可以单独进行垃圾回收,十分灵活。通过记录Region垃圾回收时间以及回收所得到的空间,维护一个优先列表,回收目标是回收价值最大Region。

每个Region都有一个Remembered Set,用于记录Region对象的引用对象所在的Region,从而避免可达性分析是的全堆扫描。

不计算维护set的草所,收集器的工作步骤如下:

  1. 初始标记
  2. 并发标记
  3. 最终标记:为了修正并发标记种,因为用户线程继续运行而导致变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Log 中。这个阶段需要停顿线程,但是可以并行执行。
  4. 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

内存分配和回收策略

Minor GC 和 Full GC

Minor GC: 回收新生代,因为新生代对象存活时间很短,因此Minor GC会频繁执行,执行速度也比较快。

Full GC:回收老年代和新生代,老年代存活时间长,因此Full GC很少执行,而且比Minor GC 慢很多。

新生代内存分析

JVM将新生代分为三个部分,一个Eden区和两个survivor区(分别叫from和to),默认比例是8:1:1。

一般情况下,新创建的对象会被分配到Eden区,这些对象经过一次Minor GC之后,如果仍然存活,就会被移动到Survivor区。对象在Survivor区中每经过一次Minor GC,年龄就会增加1岁。当年龄增加到一定程度,就会被移入老年代。

因为新生代的对象大部分都是生的快死的快,所以在新生代的垃圾回收使用的是复制算法。

  1. GC开始的时候,对象只会存在于Eden区和From Survivor区,TO 区是空的。
  2. 然后开始进行GC,Eden区的所有存活对象被移动到To区,From区中仍然存活的对象根据年龄来决定去留,如果年龄达到阈值,则被移入老年代;其余则被复制入To区。
  3. 此时From区和Eden区被清空,同时From区和To区交换身份。从而保证To区在GC开始的时候都是空的。
  4. 当To区被填满后,To区的所有对象被移入老年代。

内存分配策略

1. 对象优先在Eden分配

大多数情况下,对象直接在Eden上分配。当Eden空间不足时,发起Minor GC.

2.大对象直接进入老年代

大对象指需要连续内存的对象,比如很长的String或者数组。

经常出翔大对象会提前触发GC以获取足够打的连续空间分配给大对象。

3. 长期存活的对象直接进入老年代

即年龄大于阈值后进入老年代。

4. 动态对象年龄判定

即当Survivor中相同年龄的所有对象大小的总和大于Survivor区的一半,则年龄大于或等于该年龄的对象可以直接进入老年代。无需达到阈值。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

触发Full GC的条件

1. 调用System.gc()

只是建议虚拟机执行Full GC,虚拟机不一定会执行,不建议用这种方式。

2. 老年代空间不足

大对象,长期存活的对象太多。

为了避免该方式,尽量避免创建大对象和大数组。除此外,可以调整JVM参数调大新生代大小,让对象尽可能在新生代被回收。也可以通过调大对象进入老年代的年龄,让对象在新生代多存货一段时间。

3.空间分配担保失败

参考上一节的最后一个内容

4.JDK1.7以前的永久代空间不足

5. Concurrent Mode Failure

ClassLoader(类加载机制)

类的加载并不是一次全部加载的,而是在使用时动态加载的。

类的生命周期

类的生命周期包括以下阶段:

  1. 加载(Loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)
  6. 使用(Using)
  7. 卸载(Unloading)

类加载过程

1. 加载

该阶段会在内存中生成代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口。

获取二进制字符流的方式有:

  1. 从一个Class文件获取
  2. 从一个ZIP包中获取,比如jar
  3. 运行时计算产生,比如动态代理
  4. 由其他文件生成,比如jsp

2. 验证

该阶段确保字节流中包含的信息符符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。

可以分成以下四个流程:

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

3. 准备

正式为类变量(static修饰变量)分配内存,并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。实例变量不会再这个阶段分配内存,它将会在对象实例化时随着对象一起分配在Java堆中。

4. 解析

虚拟机将常量池中的符号引用替换为直接引用的过程。

符号引用:就是class文件中的一部分CONSTANT变量,符号引用与虚拟机实现的布局无关,引用的目标并不一定已经加载到内存中。
直接引用:指向目标的指针,相对偏移量或者时一个内简介定位到目标的句柄。如果有直接引用,那目标在内存中必然已经存在。

5. 初始化

初始化阶段才开始正式执行类中负责定义的Java程序代码,虚拟机执行类构造器方法,对类的静态变量赋予正确的初始值,JVM负责对类开始进行初始化。

类加载加载器

类加载器有以下几种:

1. BootStrap ClassLoader

启动类加载器,主要负责jdk_home/lib目录下的核心api或者x-bootclasspath指定的jar包的装入工作

2. Extension ClassLoader

扩展类加载器,主要负责jdk_home/lib/ext目录下的jar包,或者-

Djava.ext.dirs指定目录下的jar包装入工作。

3. System ClassLoader

系统类加载器,主要负责java -classpath/Djava.class/path所指目录下的类和jar。

4. User Custom ClassLoader

用户自定义加载器,通过java.lang.classloader的子类动态加载。

双亲委派模式

双亲委派工作模式是这样的:

如果一格类加载器收到类加载的请求,它首先不会自己去加载这个类,而是给父加载器去完成,依次向上,所以所有的类加载请求都应该被传递到顶层的启动类加载器,只有父加载器找不到所需类,子加载器才尝试加载该类。

双亲委派模式的好处如下:

  1. 通过带有优先级的层级关可以避免类的重复加载,保证唯一性。
  2. 保证Java程序安全稳定运行,Java核心API定义类型不会被随意替换。

获取类的对象的三种方式

  1. 类名.class,比如String.class
  2. 对象.getClass(),testString.getClass()
  3. Class.forName(),比如Class.forName("java.lang.String")

For you, a thousand times over!