上文已经介绍了JVM内存模型中线程私有的部分(虚拟机栈、本地方发栈、程序计数器),那么本篇文章就来探讨下JVM中线程共享的区域:堆、方法区
方法区
方法区是JVM规范中定义的一个逻辑内存,在JDK1.8以前对方法区的实现叫做“永久代”,然而在JDK1.8以后讲“永久代”废弃,改为“元空间”对其方法区进行实现,并且存储位置是本地内存,但是它依旧是JVM的逻辑区域。方法区存储了每一个类的结构信息 、常量、静态变量、即时编译器编译后的代码缓存等数据。
常量池
静态常量池
Class文件中除了有类的版本、字段、方法、接口等信息外,还有一个常量池用于存放编译器间生成的字面量和符号引用。这些内容在类加载完成后转存到运行时常量池中。
- 字面量:给基本类型变量和包装类、字符串赋值的方式就是字面量,比如int a = 1,这里的1就是字面量;比如String b = "b",这里的"b"就是字面量。
- 符号引用:符号引用是以一组符号来描述引用的目标。因为在java文件编译成class文件的时候,虚拟机并不知道字段所引用的对象的实际内存地址,就用符号引用来替代,等到类加载解析阶段将其符号引用转为内存地址的直接引用。符号引用是可以以任何形式的字面量,比如类的全限定名。只要能唯一定位到目标位置都可以。
运行时常量池
运行时常量池具有动态特性,它是每一个类(或接口)在运行时的表现形式,它存储了类加载解析后的符号引用和字面量。在JDK1.7以后将其实际内存移入堆中,但逻辑上仍属于方法区。
字符串常量池
在JDK1.8后字符串常量池划入堆内存,因为String类型的对象在开发过程中使用的频率和它的内存结构特殊,所以JVM开发者设计了字符串常量池来提升性能。
堆
堆内存作为JVM内存模型中占比最大的空间它的重要性显而易见。下文的对象创建和GC算法、GC收集器都和堆内存息息相关。我们程序中几乎所有(不是全部)对象都存放在堆内存中,堆内存也是支持动态伸缩,当申请不了更多的可用内存时便会抛出OOM。我们可以通过JVM启动参数对其设置内存大小:
-Xms 堆的最小内存,例 -Xms512m
-Xmx 堆的最大内存
-Xmn 堆的新生代大小
-XX:NewSize 新生代最小值
-XX:MaxNewSize 新生代最大值
对象的内存布局
一个对象在堆内存中的内存布局可以分三块
- 对象头:1.存储对象在运行时的数据,比如
哈希码、GC分代年龄、锁状态标志、偏向线程ID等等
,这部分区域称之为Mark Word
。2.类型指针:JVM通过这个指针来确定此对象属于哪个类的实例。3.如果对象是一个数组,还会存储数组的长度信息。 - 实例数据:存放的就是我们代码定义的实例变量所对应的数据和父类继承下来的实例变量,实例数据的顺序受我们编码顺序相关。
-
对齐填充:没有特殊的含义,仅是为了起到占位符的作用。JVM管理对象,对象的大小必须是8字节的整数倍,方便管理,如果对象的大小不是8的整数倍就需要对齐填充之最近的8整数倍。
对象的访问/定位
我们Java程序员常说的对象引用,就是通过虚拟机栈中reference
数据来定位堆内存中的对象。因为JVM规范中并没有指定这个引用通过什么方式去实现,所以目前定位方式大致分为两种:
1.句柄
2.直接引用
目前HotSpot虚拟机默认采用直接引用方式,更加方便。但是并不代表句柄方式不适合,句柄也有很大的好处,就是句柄存储的是稳定的句柄地址,当对象被移动的时候不需要修改栈中的数据(垃圾收集会涉及对象地址的移动)。而使用直接引用方式最大的好处就是访问速度更快,节省了一步寻址的时间开销。
对象的创建和内存分配
对象的创建分为两种模式:1.指针碰撞。2.空闲列表。
1.指针碰撞
如果堆内存中的空闲内存是绝对整齐的(用的的内存放一边,空闲的放另一边),此时JVM在创建对象分配内存的时候只需要把指针向空闲的区域移动指定大小(对象的大小在类加载的时候已经可以确定,且是8字节的整数倍大小)。这种分配方式称之为指针碰撞
。
2.空闲列表
空闲列表相对于指针碰撞要复杂很多,如果堆内存中可用内存不是整齐的那么就不能使用指针碰撞
这种方式,那就JVM就需要额外维护一个数据池,用来记录内存中哪些地址是可用的内存。当分配的时候从数据池中找出足够的空间用来分配对象,并且更新数据池里的数据记录,这种分配方式称之为空闲列表
。
那么问题来了,JVM到底选择哪种模式进行的对象分配呢?
选择哪种方式是由堆内存的整齐度决定,而内存的整齐度取决于采用的是哪种GC收集器是否带有空间压缩整理功能决定。当使用Serial、Paraller Scavenge、ParNew等收集器时系统采用指针碰撞,如果使用CMS收集器只能用空闲列表方式。每种垃圾收集器的区别下文中解答。
对象分配并发安全问题
Java程序天生是多线程的,如果一个创建对象的程序处于多线程并发执行,那么就会产生线程安全问题,试想:如果A线程准备将对象分配在堆内存的x_0001处,此时B线程也执行到创建对象,在申请空间的时候发现x_0001处未分配....后续的问题就不用说了。那么JVM是怎么解决这个问题的呢?
1.CAS无锁机制
这种方式最易想到,采用锁机制进行线程并发问题可以很好地解决。
但是毕竟全局使用CAS锁机制,如果在高并发情况而且创建对象的时间比较长,那么这种方式性能的问题就凸显了出来,于是第二种方式孕育而生。
2.TLAB(本地线程分配缓冲Thread Local Allocation Buffer)
TLAB
顾名思义,JVM将每个线程在堆中预先分配一块私有内存空间 ,这用每个独立的线程都有属于自己的Buffer,如果有对象需要分配那么就会在自己的Buffer中创建对象,当Buffer用满了以后就会重新分配缓冲区,此时这个阶段是需要CAS
锁机制保证安全。JVM可以通过制定启动参数-XX:+/-UseTLAB来指定。
内存空间初始化
对象分配内存完成之后,JVM需要将对象的实例数据赋予默认值,比如对象类型赋值null,基本类型赋值默认值,例如int -> 0,boolean -> false。
设置
JVM还需要堆对象进行必要的设置,例如这个对象属于哪个类的实例、GC分代年龄等,这些信息存储在对象头
中。需要注意的是对象的哈希码并不是在此刻计算(实际上的哈希码会延后到真正调用Object::hashCode()方法时才计算赋值)
初始化
执行构造方法,实例变量赋真实值。
对象的引用和分配策略
对象的引用
在JDK1.2以后,Java对引用的概念扩充为4类:
- 强引用:例如Object o = new Object()方式都属于强引用,只要强引用关系存在,那么所引用的对象都不会被回收
- 软引用(SoftReferenct):指定一些有用但是非必须的引用关系,如果系统将要发生OOM之前,这些引用的对象将被回收。
/**
* -Xms20m -Xmx50m
* @author Minor
*/
public class JvmDemo {
public static void main(String[] args) {
Student student = new Student("张三",24);
// 创建软引用关系
SoftReference<Student> softReference = new SoftReference<>(student);
// 此时,将student对象置为null
student = null;
// 第一次GC,看看是否会空指针
System.gc();
System.out.printf(softReference.get().toString());
// 模拟OOM
List<byte[]> oomList = new LinkedList<>();
try {
for (;;){
oomList.add(new byte[1024*1024*10]);
}
}catch (OutOfMemoryError error){
System.out.printf(softReference.get().toString());
}
}
@Data
private static class Student{
private String name;
private Integer age;
Student(String name ,Integer age){
this.name = name;
this.age = age;
}
}
}
我们来看看软引用的结果:
- 弱引用(Weak Rererence):比软引用更弱一级的引用关系,此关系引用给的对象在下一次垃圾回收的时候必定被回收。
/**
* -Xms20m -Xmx50m
* @author Minor
*/
public class JvmDemo {
public static void main(String[] args) {
Student student = new Student("张三",24);
// 创建弱引用关系
WeakReference<Student> softReference = new WeakReference<>(student);
// 此时,将student对象置为null
student = null;
System.gc();
System.out.printf(softReference.get().toString());
}
@Data
private static class Student{
private String name;
private Integer age;
Student(String name ,Integer age){
this.name = name;
this.age = age;
}
}
}
我们来看看弱引用的结果:
- 虚引用:又称作“幽灵引用”,它是Java中最弱的一种引用关系,目的只是为了能在这个对象被回收时收到一个系统通知。
对象的分配策略
1.栈上分配
上文说到,对象“几乎”都是在堆内存中存储的,但是也有例外。
- 逃逸分析:分析对象的作用域,如果一个对象在方法中定义后它可以被外部方法所引用,那么此对象成为逃逸对象。否则,对象无法逃逸方法的作用域,如果一个对象不会逃逸出方法之外,那么在对象分配的时候可以考虑在虚拟机栈上分配对象,提高效率。因为此类对象不会逃逸出方法中,方法执行完毕,栈帧出栈,线程结束时对象也跟着消亡,不用垃圾回收。
2.优先在新生代Eden区分配
在大部分情况下对象是在Eden区中分配,如果此时Eden区没有足够的空间进行分配时将会发生新生代的一次回收Young GC/Minor GC。
3.大对象直接进入老年代
大对象指的是需要大量连续空间的Java对象,比如大的数组,很长的字符串(底层也是数组)。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数来设置指定大于该值的对象直接分配在老年代。避免了大的对象在新生代中来回复制产生性能消耗(后文会讲到GC回收算法)
设计目的:1.避免频繁的内存复制造成性能消耗.
2.避免提前垃圾回收
注意:-XX:PretenureSizeThreshold参数只能对Serial和ParNew收集器生效。
4.长期存活的对象直接进入老年代(分代年龄满15)
每个对象都已一份对象头,对象头中存储着一份对象的分代年龄。目前主流的新生代垃圾回收算法采用复制算法,那么对象在Eden区出生并经过第一次Minor GC时如果对象还存活,那么对象的分代年龄+1,然后将其放入幸存区。幸存区有两个大小相等的区域From和To,每一次Minor GC以后,如果对象仍然存活那么对象就会在From和To区之间来回复制,每次分代年龄+1。当对象的分代年龄达到15,那么对象将进入老年代。
5.动态年龄判断进入老年代
顾名思义,并不是所有的对象必须要求分代年龄满15才能进入老年代。如果在幸存区中相同年龄的对象大小总和大于幸存区的50%时,幸存区内>=该年龄的对象直接进入老年代。
空间分配担保:在发生 Minor GC 之前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立, 那么 Minor GC 可以确保是安全的。 如果不成立, 则虚拟机会HandlePromotionFailure 设置值是否允许担保失败。 如果允许, 那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 将尝试着进行一次 Minor GC, 尽管这次 Minor GC 是有风险的, 如果担保失败则会进行一次 Full GC(回收整个堆和方法区); 如果小于, 或者 HandlePromotionFailure 设置不允许冒险, 那这时也要改为进行一次 Full GC。
对象存活的判定
栈上分配
的对象因为和线程的生命周期一致所以不存在对象存活的判定,也不涉及垃圾回收。
1.引用计数法
在对象中添加一个引用计数器,每当有引用指向它时计数器+1,当引用失效时就-1;任何对象只要计数器不为0,那么他就不会被回收。
特点:
- 原理简单,效率高
- 存在循环引用问题,无法回收此类对象
在Java领域引用计数法
并没有选择此类型的算法,但是微软的COM技术、Python语言在使用这种方式。
2.可达性分析算法
目前主流的高级语言(例如Java、C#)都是通过
可达性分析算法
来确定对象是否可以被回收。此算法的基本思路就是通过一系列的GC Roots
对象作为起点,从这个节点向下遍历,形成一个引用链
,当一个对象到GC Roots
没有任何引用链相连时,那么此对象是无用对象需要回收。比如上图中白色部分的对象,即便对象有引用,但是引用连追溯不到GC Roots
。那么固定可作为
GC Roots
的对象包括以下几种:
- 虚拟机栈(本地变量表)中引用的对象,栈中使用到的参数、局部变量、临时变量。
- 方法区中类变量(静态变量)引用的对象。
- 方法区中常量引用的对象,比如字符串常量池里的引用。
- 本地方法栈中JNI引用的对象(Native方法)。
- JVM的内部引用(Class对象、异常对象、类加载器)。
- 被同步锁(synchronized)持有的对象。
- 反应JVM内部状况的JMXBean、JVMTI中注册的回调、代码缓存。
- JVM运行时临时性的对象,比如GC跨带引用的对象。
垃圾回收算法
名词解释:
- 新生代垃圾回收Minor GC/Young GC
- 老年代垃圾回收Major GC/Old GC
- 全回收Full GC/回收整个堆和方法区
1.复制回收算法
将内存按照容量划分为大小相等的A、B两个区域,每次只是用一个区域。当A区域内存用完,将存活着的对象复制到B区域,然后清空A区,这样每次都是对半进行内存回收。
特点:
- 内存利用率只有50%
- 不存在内存碎片问题
- 复制对象时还需要修改对象的引用地址
目前复制回收算法
用于新生代,因为大部分对象的生命都很短暂,需要复制的对象并不是很多所以效率相对较高。
1.1JVM的Appel式回收
将堆内存的新生代划分为较大的Eden区和较小的两个一样大小的Survivor区(From、To),他们的比例在前文的图中已经说明是8:1:1,因为绝大部分的对象的生命很短暂,幸存区没必要设置太大。
2.标记-清除算法
此算法分为两个阶段:标记
和清除
- 标记:根据
可达性分析算法
扫描出可回收的对象,然后将对象进行回收标记。 - 清除:再次扫码被标记回收的对象进行垃圾回收。
特点:
- 因为需要扫描两遍,效率上略低
- 存在
内存碎片
问题 - 适用于老年代
内存碎片
使用
标记-清除
算法如上图所示,如果此时一个大对象(需要连续内存)需要分配,虽然堆内存总的空间是够用的,但是并没有连续的可用空间导致大对象放不下,提前造成GC。
3.标记-整理算法
此算法分为两步:标记和整理
- 标记:根据
可达性分析算法
扫描出可回收的对象,然后将对象进行回收标记。 - 整理:将无用对象进行回收,将存活的对象往内存的一端移动,进行空间整理。
特点:
- 需要整理内存,效率略低
-
因为有整理,所以没有内存碎片问题
因为涉及到对象的移动,改变对象的引用地址,所以在整理的时候需要暂停用户线程,加重系统负担,目前此算法用户老年代的回收算法支持。
垃圾收集器
1.Serial / Serial Old 收集器
JVM最早的垃圾收集器,串行化执行模式,它是一个单线程的收集器,适合内从只有几十到一两百兆的堆空间进行回收。因为它是单线程进行垃圾回收所以用户线程停顿十分明显,目前JDK默认已经没有使用此收集器,但是可以使用-XX+UseSerialGCz指定开启串行收集器。Serial使用复制回收算法
进行新生代回收,Serial Old使用标记-整理算法
进行老年代回收工作。
Parallel Scavenge / Parallel Old 收集器
从JDK1.3开始,JVM就采用了多线程的垃圾回收机制,Parallel收集器更关注系统的吞吐量,能有效的利用CPU时间尽快完成垃圾回收任务,此收集器使用户几百到几千兆的堆内存空间进行回收;可以通过-XX+UseParallelGC开启此收集器,JDK1.8默认使用此垃圾收集器。Parallel Scavenge使用复制回收算法
进行新生代回收,Parallel Old使用标记-整理算法
进行老年代回收工作。
ParNew 收集器
实质上是Serial收集器的多线程版本,它只负责新生代的垃圾回收,根据上文图中的垃圾收集器分配图来看,它一般配合Serial和CMS配合使用。在JDK9以后将其合并到CMS中。
CMS 收集器
CMS收集器是一款追求最短回收停顿时间为目标的收集器。比较特殊的是,CMS收集器是回收老年代,而且是基于标记-清除算法
实现,他的整个工作过程分为4步:
- 初始标记:此过程很短暂,仅仅只是标记一下GC Roots直连的对象。
- 并发标记:和用户线程同时进行,此过程进行GC Roots引用链的扫描,此过程相对于第一阶段比较漫长。
- 重新标记:因为并发标记阶段和用户线程共同执行,导致标记产生变动的一部分对象,相对于第二阶段此阶段过程比较快。
- 并发清除:和用户线程同时执行,清除标记的对象,完成垃圾回收工作。
我们可以使用-XX:UseConcmarkSweepGC开启CMS老年代和ParNew新生代的回收器。
CMS收集器是一个具有划时代意义的垃圾收集器,但是它也有缺点:
- CPU资源敏感:采用并发手机机制,当处理器核心数比较少时,CMS工作时会对用户体验造成影响。
- 浮动垃圾:因为CMS在并发清理阶段用户线程仍然一起执行着,伴随着程序运行当然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法处理。所以CMS收集器会预留一部分内存用来处理这一部分数据,在JDK1.6版本中老年代空间使用率的阈值是92%,如果预留的内存不够用的时候会出现Concurrent Mode Failure,导致新的Full GC产生,这是JVM会临时启用Serial Old来替代CMS。
- 内存碎片:前文已经提到,CMS是唯一一个采用
标记-清除算法
进行回收的收集器,所以会产生内存碎片问题。
之所以CMS采用标记-清除算法
是因为如果采用标记-整理算法
会涉及到对象内存地址的移动,此时需要暂停用户线程来修改栈中的地址,这和CMS设计的初衷不符。
G1 收集器
有一个划时代意义的收集器,因为传统的垃圾收集器仍然对线程暂停的时间不可预测,为了实现线程暂停的时间变为可预测,G1收集器将堆内存做了较大改变,将其划分为大小相等且独立的区域Region,每一个Region都可以根据需要扮演Eden、Survivor、old。Region中有一类特殊的Humongous区域,专门用来存储大对象,只要对象大小超过一个Region容量的一般即判定为大对象,如果对象更大将会放在多个连续的Humongous中,Humongous默认当做老年代来看待。我们可以通过-XX:+UseG1GC来开启使用G1收集器。G1新生代采用复制算法
,老年代使用标记-整理算法
G1的过程分为以下几步:
- 初始标记:仅只是标记一下 GC Roots 能直接关联到的对象, 并且修改 TAMS 指针的值, 让下一阶段用户线程并发运行时, 能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程, 但耗时很短, 而且是借用进行 Minor GC 的时候同步完成的, 所以 G1 收集器在这个阶段实际并没有额外的停顿
TAMS
:要达到 GC 与用户线程并发运行, 必须要解决回收过程中新对象的分配, 所以 G1 为每一个 Region 区域设计了两个名为 TAMS(Top at Mark Start) 的指针,
从 Region 区域划出一部分空间用于记录并发回收过程中的新对象。 这样的对象认为它们是存活的, 不纳入垃圾回收范围。 - 并发标记:从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。 当对象图扫描 完 成以后并发时有引用变动的对象,这些对象会漏标漏 标的对象会被一个叫做SATB(snapshot-at-the-beginning)算法来解决,我们以后来探讨一下这个算法。
- 最终标记:对用户线程做另一个短暂的暂停, 用于处理并发阶段结后仍遗留下来的最后那少量的漏标对象。
- 筛选回收:负责更新 Region 的统计数据, 对各个 Region 的回收价值和成本进行排序, 根据用户所期望的停顿时间来制定回收计划, 可以自由选择任意多个 Region 构成回收集, 然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中, 再清理掉整个旧 Region 的全部空间。 这里的操作涉及存活对象的移动,是必须暂停用户线程, 由多条收集器线程并行完成的。
G1收集器的特点:
- 充分利用CPU资源,缩短用户线程停顿时间。
- 分代收集
- 不存在内存碎片,空间整齐