JVM(Java Virtual Machine)虚拟机的内存模型与垃圾回收
写这篇博客时参考了很多博主的文章,有的可能忘记了在文章中注明,博主看到请联系我
所有的Java程序都运行在虚拟机内部
它是由一组规范所定义出的抽象计算机,主要任务是将字节码装载到其内部,解释、编译为对于平台上的机器指令执行
一、JVM内存模型
1. JDK7的内存模型
-
Java堆区:是一块用于存储对象实例的内存区,同时也是GC执行垃圾回收的重点区域。在JVM启动时创建,实际内存空间不连续。
-
方法区:或者将他称为永久代(PermGen),该区中存储了Java类信息、常量池、静态变量等。需要注意的是:Java虚拟机规范对方法区的实现方式并没有明确要求,在HotSopt VM中,方法区知识逻辑上的独立,实际还是堆区的一部分,生命周期与堆区相同。
-
PC寄存器(PC计数器):为每一个线程分配一个PC寄存器,用来记录各个线程正在执行的当前字节码指令地址和下一条指令地址,生命周期与线程生命周期同步。
-
Java栈(虚拟机栈):存放局部变量表、操作数栈,以及方法出口等信息,局部变量包括:原始数据类型、对象引用等类型。生命周期与线程生命周期同步。
-
本地方法栈:用于支持本地方法,就是由native修饰的方法(例:System.arraycopy()),作用和Java栈作用类似。且Java虚拟机规范并没有明确要求本地方法栈的具体实现方式,就是说如果不需要本地方法,就可以不实现
方法区与永久代的区别:方法区只是JVM规范定义,而永久代为具体的实现
想了解HotSpot VM发展历史点此:什么是HotSpot VM & 深入理解Java虚拟机 JVM
HotSpot VM:是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机
2. 常量池
常量池:分为两种形态:静态常量池和运行时常量池
- 静态常量池:即*.class文件中的常量池,主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References),字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了三种类型的常量:类和接口的全限定名;字段名称和描述符;方法名称和描述符
- 运行时常量池:是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区
常量池的好处
常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。
例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。
(1)节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。
(2)节省运行时间:比较字符串时,==比equals()快。对于两个引用变量,只用==判断引用是否相等,也就可以判断实际值是否相等
以String字符串为例:
String s1 = "Hello";
String s2 = "Hello";
String s3 = "Hel" + "lo";//在class文件中被优化成String s3 = "Hello"
String s4 = "Hel" + new String("lo");
String s5 = new String("Hello");
String s6 = s5.intern();
String s7 = "H";
String s8 = "ello";
String s9 = s7 + s8;
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // true
System.out.println(s1 == s4); // false
System.out.println(s1 == s9); // false
System.out.println(s4 == s5); // false
System.out.println(s5 == s6); //false
System.out.println(s1 == s6); // true
原因:
s1 == s2这个非常好理解,s1、s2在赋值时,均使用的字符串字面量。
s1 == s3这个地方有个坑,s3虽然是动态拼接出来的字符串,但是所有参与拼接的部分都是已知的字面量,在编译期间,这种拼接会被优化,编译器直接帮你拼好,因此String s3 = “Hel” + “lo”;在class文件中被优化成String s3 = “Hello”,所以s1 == s3成立。只有使用引号包含文本的方式创建的String对象之间使用“+”连接产生的新对象才会被加入字符串池中。
s1 == s4当然不相等,s4虽然也是拼接出来的,但new String(“lo”)这部分不是已知字面量,是一个不可预料的部分,编译器不会优化,必须等到运行时才可以确定结果。
s1 == s6这两个相等完全归功于intern方法,s5在堆中,内容为Hello ,intern方法会尝试将Hello字符串添加到常量池中,并返回其在常量池中的地址,因为常量池中已经有了Hello字符串,所以intern方法直接返回地址。
s1 == s9也不相等,道理差不多,虽然s7、s8在赋值的时候使用的字符串字面量,但是拼接成s9的时候,s7、s8作为两个变量,不能在编译期被确定,所以不做优化,只能等到运行时,在堆中创建s7、s8拼接成的新字符串,在堆中地址不确定,不可能与方法区常量池中的s1地址相同。
本节转自深入浅出java常量池
3. JDK8的内存模型改进
在JDK8 中,HotSpot已经没有永久代这个区间了,取而代之是一个叫做Metaspace的元空间,而运行时常量池和静态变量都存储到了堆中,MetaSpace存储类的元数据,MetaSpace直接申请在本地内存中。其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。譬如符号引用(Symbols)转移到了native heap;字面量和类的静态变量转移到了java heap。但永久代仍存在于JDK1.7中,并没完全移除。
如图:
为什么要做这个转换?
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- Oracle 可能会将HotSpot 与 JRockit 合二为一。
转自Java8内存模型—永久代(PermGen)和元空间(Metaspace)
二、GC垃圾回收
1. 什么是垃圾回收
首先,在了解G1之前,我们需要清楚的知道,垃圾回收是什么?简单的说垃圾回收就是回收内存中不再使用的对象。
2. 垃圾回收的基本步骤:
- 查找内存中不再使用的对象
- 释放这些对象占用的内存
2.1 查找内存中不再使用的对象
那么问题来了,如何判断哪些对象不再被使用呢?我们也有2个方法:
2.1.1 引用计数法
引用计数法就是如果一个对象没有被任何引用指向,则可视之为垃圾。这种方法的缺点就是不能检测到环的存在。
2.1.2 根搜索算法
根搜索算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
- Java虚拟机栈中引用的对象:比如方法里面定义这种局部变量 User user= new User();
- 静态属性引用的对象:比如 private static User user = new User();
- 常量引用的对象:比如 private static final User user = new User();
- 本地方法栈中引用的对象
2.2 释放这些对象占用的内存
常见的方式有复制或者直接清理,但是直接清理会存在内存碎片,于是就会产生了清理再压缩的方式。
总得来说就产生了三种类型的回收算法。
2.2.1 标记-清除
它是最基础的收集算法是“标记-清除”(Mark-Sweep)算法,分两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一个的垃圾收集动作。
2.2.2 复制算法
为了解决效率问题,一种称为复制(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上,然后再把已经使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。代价是内存缩小为原来的一半。
IBM研究表明98%的对象是“朝生夕死”,不需要按照1-1的比例来划分内存空间,而是将内存分为一块较大的”Eden“空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor,留下一块Survivor空间。Hotspot虚拟机默认Eden和Survivor空间大小之比为8:1。
2.2.3 标记-压缩
复制收集算法在对象成活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以,有人提出一种”标记-整理“Mark-Compact算法。
标记过程仍然和标记-清除一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界以外的内存。
2.2.4 分代收集算法
由于对象的存活时间有长有短,所以对于存活时间长的对象,减少被gc的次数可以避免不必要的开销。这样我们就把内存分成新生代和老年代,新生代存放刚创建的和存活时间比较短的对象,老年代存放存活时间比较长的对象。这样每次仅仅清理年轻代,老年代仅在必要时时再做清理可以极大的提高GC效率,节省GC时间。
目前几乎所有的GC都是分代收集算法,所以Java堆区要跟进一步细分的话,还可以分为新生代(YoungGen)和老年代(OldGen)。
其中新生代又可以划分为Eden空间、From Survivor空间和To Survivor空间。在新生代中大量使用的算法为:复制算法。当生成一个对象实例时,便会在新生代,更准确的说是Eden空间中开辟一块空间。当执行一次GC时,Eden空间中还活着的对象便会被复制到To空间中,并且之前已经执行过一次GC并在From空间中存活下来的对象也会被放到To空间中。但是需要注意的是:有一种特殊情况不会被复制到To空间,就是当对象的存活时间超过一个指定的阈值时,或直接晋升到老年代空间中。当所有存活的对象都在To空间后,GC将To空间以外的剩余两个间全部清空,然后To空间与From空间互换位置,就是说To空间其实扮演了一个类似于中间变量的东西。这就是复制算法。
在老年代使用的算法为:标记-清除算法或标记-压缩算法
三、Java垃圾收集器的历史
Stop The World机制(简称STW):
即在执行垃圾收集算法时,必须在一个能确保一致性的快照中进行,所以Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起。此时,系统只能允许GC线程进行运行,其他线程则会全部暂停,等待GC线程执行完毕后才能再次运行。
这对于很多的应用程序,尤其是那些对于实时性要求很高的程序来说是难以接受的。但是目前所有的虚拟机都无法避免Stop the world机制
1. Serial收集器 – 串行
在jdk1.3.1之前,java虚拟机仅仅能使用Serial收集器。 Serial收集器是一个单线程的收集器,采用复制算法和Stop the world。缺省作为HotSpot中Client模式下的新生代垃圾收集器。
同时Serial收集器还提供了用于执行老年代垃圾收集器的Serial Old收集器,采用标记-压缩算法。
2. ParNew收集器 – 并行
ParNew收集器其实就是Serial收集器的多线程版本,ParNew收集器除了采用并行性回收方式外,两者几乎没有任何区别。
但是我们要注意,在CPU仅限于单个的情况下,ParNew收集器不一定比Serial收集器更高效,它的优势集中体现多CPU、多核心的宿主环境中。
3. Parallel收集器 – 并行
Parallel收集器其实就是也称吞吐量收集器,它和ParNew收集器一样,采用并行回收、复制算法和stop the world机制。不同的是Parallel可以控制程序的吞吐量大小。
我们知道,在垃圾收集器中吞吐量和低延迟着两个目标其实是存在相互竞争和矛盾,如果以吞吐量优先,那么降低内存回收的执行频率则是必然的,就意味着GC要更长的暂停时间来执行内存回收。反之,以低延迟优先,则会频繁的执行内存回收。
* 4. CMS(Concurrent-Mark-Sweep)收集器 – 并发
CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类尤其重视服务的响应速度,希望系统停顿时间最短。CMS收集器就非常符合这类应用的需求。
CMS基于 标记-清除算法实现。整个过程分为4个步骤:
- 初始标记(CMS initial mark) -stop the world
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark) -stop the world
- 并发清除(CMS concurrent sweep)
初始标记,重新标记这两个步骤仍然需要Stop The World, 初始标记仅仅标记以下GC Roots能直接关联的对象,速度很快。
并发标记就是进行GC Roots Tracing的过程,找出垃圾对象。
而重新标记阶段则是为了修正并发标记期间因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段停顿比初始标记稍微长,但远比并发标记的时间短。
整个过程耗时最长的并发标记和并发清除过程,收集器都可以与用户线程一起工作。总体上来说,CMS收集器的内存回收过程与用户线程一起并发执行的。
CMS特点:并发收集,低停顿。
CMS缺点:
- CMS收集器无法处理浮动垃圾。由于CMS并发清理时,用户线程还在运行,伴随产生新垃圾,而这一部分出现在标记之后,只能下次GC时再清理。这一部分垃圾就称为”浮动垃圾“。
- 由于CMS运行时还需要给用户空间继续运行,则不能等老年代几乎被填满再进行收集,需要预留一部分空间提供并发收集时,用户程序运行。
- CMS基于”标记-清除“算法实现的,则会产生大量空间碎片,空间碎片过多时,没有连续空间分配给大对象,不得不提前触发一次FUll GC。
* 5 G1(Garbage-First)收集器 – 并发
G1的设计原则就是简单可行的性能调优
在2012年才在jdk1.7u4中可用。oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。
G1在设计上的改变可以说是有革命性的意义,它不在是传统的新生代,老年代的物理空间的划分,取而代之的是,G1算法将堆划分为2048个大小相等的独立区域(Region),区域的大小只能是2的幂次方,如果不指定堆的大小,则在堆初始化时计算Region的大小,这些区域的分为四部分:Eden、Survivor、Old、Humongous。Humongous是一块特殊的区域,它用来专门存放巨型对象。如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。当一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
当G1收集器执行内存回收时,会优先释放掉整个Java堆区中占用内存较大的Region块,从而避免了扫描整个Java堆区。
G1执行垃圾回收过程分为6个阶段:
- 初始标记阶段:对根进行标记,采用STW机制
- 根区域扫描阶段:在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。该阶段与应用程序同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。
- 并发标记阶段:在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被STW年轻代垃圾回收中断
- 再次标记阶段:该阶段是STW回收,帮助完成标记周期。类似于CMS的重新标记
- 清除阶段:停止所有用户线程,计算出所有活着的对象,并完全释放一些自由的Region块,然后并发重置空闲的一些Region块,并将踢他们放回至空闲列表中。
- 拷贝阶段:基于STW机制,将存活的对象复制到从未使用过的Region块中
本节转自 https://www.cnblogs.com/woshimrf/p/jvm-garbage.html
参考:Java虚拟机精讲 高翔龙