上文已经介绍了JVM内存模型中线程私有的部分(虚拟机栈、本地方发栈、程序计数器),那么本篇文章就来探讨下JVM中线程共享的区域:堆、方法区
方法区是JVM规范中定义的一个逻辑内存,在JDK1.8以前对方法区的实现叫做“永久代”,然而在JDK1.8以后讲“永久代”废弃,改为“元空间”对其方法区进行实现,并且存储位置是本地内存,但是它依旧是JVM的逻辑区域。方法区存储了每一个类的结构信息 、常量、静态变量、即时编译器编译后的代码缓存等数据。
Class文件中除了有类的版本、字段、方法、接口等信息外,还有一个常量池用于存放编译器间生成的字面量和符号引用。这些内容在类加载完成后转存到运行时常量池中。
运行时常量池具有动态特性,它是每一个类(或接口)在运行时的表现形式,它存储了类加载解析后的符号引用和字面量。在JDK1.7以后将其实际内存移入堆中,但逻辑上仍属于方法区。
在JDK1.8后字符串常量池划入堆内存,因为String类型的对象在开发过程中使用的频率和它的内存结构特殊,所以JVM开发者设计了字符串常量池来提升性能。
堆内存作为JVM内存模型中占比最大的空间它的重要性显而易见。下文的对象创建和GC算法、GC收集器都和堆内存息息相关。我们程序中几乎所有(不是全部)对象都存放在堆内存中,堆内存也是支持动态伸缩,当申请不了更多的可用内存时便会抛出OOM。我们可以通过JVM启动参数对其设置内存大小: -Xms 堆的最小内存,例 -Xms512m -Xmx 堆的最大内存 -Xmn 堆的新生代大小 -XX:NewSize 新生代最小值 -XX:MaxNewSize 新生代最大值
一个对象在堆内存中的内存布局可以分三块
哈希码、GC分代年龄、锁状态标志、偏向线程ID等等
,这部分区域称之为 Mark Word
。2.类型指针:JVM通过这个指针来确定此对象属于哪个类的实例。3.如果对象是一个数组,还会存储数组的长度信息。 我们Java程序员常说的对象引用,就是通过虚拟机栈中 reference
数据来定位堆内存中的对象。因为JVM规范中并没有指定这个引用通过什么方式去实现,所以目前定位方式大致分为两种:
目前HotSpot虚拟机默认采用直接引用方式,更加方便。但是并不代表句柄方式不适合,句柄也有很大的好处,就是句柄存储的是稳定的句柄地址,当对象被移动的时候不需要修改栈中的数据(垃圾收集会涉及对象地址的移动)。而使用直接引用方式最大的好处就是访问速度更快,节省了一步寻址的时间开销。
对象的创建分为两种模式:1.指针碰撞。2.空闲列表。
如果堆内存中的空闲内存是绝对整齐的(用的的内存放一边,空闲的放另一边),此时JVM在创建对象分配内存的时候只需要把指针向空闲的区域移动指定大小(对象的大小在类加载的时候已经可以确定,且是8字节的整数倍大小)。这种分配方式称之为 指针碰撞
。
空闲列表相对于指针碰撞要复杂很多,如果堆内存中可用内存不是整齐的那么就不能使用 指针碰撞
这种方式,那就JVM就需要额外维护一个数据池,用来记录内存中哪些地址是可用的内存。当分配的时候从数据池中找出足够的空间用来分配对象,并且更新数据池里的数据记录,这种分配方式称之为 空闲列表
。
那么问题来了,JVM到底选择哪种模式进行的对象分配呢? 复制代码
选择哪种方式是由堆内存的整齐度决定,而内存的整齐度取决于采用的是哪种GC收集器是否带有空间压缩整理功能决定。当使用Serial、Paraller Scavenge、ParNew等收集器时系统采用指针碰撞,如果使用CMS收集器只能用空闲列表方式。每种垃圾收集器的区别下文中解答。
Java程序天生是多线程的,如果一个创建对象的程序处于多线程并发执行,那么就会产生线程安全问题,试想:如果A线程准备将对象分配在堆内存的x_0001处,此时B线程也执行到创建对象,在申请空间的时候发现x_0001处未分配....后续的问题就不用说了。那么JVM是怎么解决这个问题的呢?
这种方式最易想到,采用锁机制进行线程并发问题可以很好地解决。
但是毕竟全局使用CAS锁机制,如果在高并发情况而且创建对象的时间比较长,那么这种方式性能的问题就凸显了出来,于是第二种方式孕育而生。
TLAB
顾名思义,JVM将每个线程在堆中预先分配一块私有内存空间 ,这用每个独立的线程都有属于自己的Buffer,如果有对象需要分配那么就会在自己的Buffer中创建对象,当Buffer用满了以后就会重新分配缓冲区,此时这个阶段是需要 CAS
锁机制保证安全。JVM可以通过制定启动参数-XX:+/-UseTLAB来指定。
对象分配内存完成之后,JVM需要将对象的实例数据赋予默认值,比如对象类型赋值null,基本类型赋值默认值,例如int -> 0,boolean -> false。
JVM还需要堆对象进行必要的设置,例如这个对象属于哪个类的实例、GC分代年龄等,这些信息存储在 对象头
中。需要注意的是对象的哈希码并不是在此刻计算(实际上的哈希码会延后到真正调用Object::hashCode()方法时才计算赋值)
执行构造方法,实例变量赋真实值。
在JDK1.2以后,Java对引用的概念扩充为4类:
/** * -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; } } } 复制代码
我们来看看软引用的结果:
/** * -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; } } } 复制代码
我们来看看弱引用的结果:
上文说到,对象“几乎”都是在堆内存中存储的,但是也有例外。
在大部分情况下对象是在Eden区中分配,如果此时Eden区没有足够的空间进行分配时将会发生新生代的一次回收Young GC/Minor GC。
大对象指的是需要大量连续空间的Java对象,比如大的数组,很长的字符串(底层也是数组)。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数来设置指定大于该值的对象直接分配在老年代。避免了大的对象在新生代中来回复制产生性能消耗(后文会讲到GC回收算法) 设计目的:1.避免频繁的内存复制造成性能消耗. 2.避免提前垃圾回收 注意:-XX:PretenureSizeThreshold参数只能对Serial和ParNew收集器生效。
每个对象都已一份对象头,对象头中存储着一份对象的分代年龄。目前主流的新生代垃圾回收算法采用复制算法,那么对象在Eden区出生并经过第一次Minor GC时如果对象还存活,那么对象的分代年龄+1,然后将其放入幸存区。幸存区有两个大小相等的区域From和To,每一次Minor GC以后,如果对象仍然存活那么对象就会在From和To区之间来回复制,每次分代年龄+1。当对象的分代年龄达到15,那么对象将进入老年代。
顾名思义,并不是所有的对象必须要求分代年龄满15才能进入老年代。如果在幸存区中相同年龄的对象大小总和大于幸存区的50%时,幸存区内>=该年龄的对象直接进入老年代。
空间分配担保:在发生 Minor GC 之前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立, 那么 Minor GC 可以确保是安全的。 如果不成立, 则虚拟机会HandlePromotionFailure 设置值是否允许担保失败。 如果允许, 那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 将尝试着进行一次 Minor GC, 尽管这次 Minor GC 是有风险的, 如果担保失败则会进行一次 Full GC(回收整个堆和方法区); 如果小于, 或者 HandlePromotionFailure 设置不允许冒险, 那这时也要改为进行一次 Full GC。
栈上分配
的对象因为和线程的生命周期一致所以不存在对象存活的判定,也不涉及垃圾回收。
在对象中添加一个引用计数器,每当有引用指向它时计数器+1,当引用失效时就-1;任何对象只要计数器不为0,那么他就不会被回收。 特点:
引用计数法
并没有选择此类型的算法,但是微软的COM技术、Python语言在使用这种方式。 可达性分析算法
来确定对象是否可以被回收。此算法的基本思路就是通过一系列的
GC Roots
对象作为起点,从这个节点向下遍历,形成一个
引用链
,当一个对象到
GC Roots
没有任何引用链相连时,那么此对象是无用对象需要回收。比如上图中白色部分的对象,即便对象有引用,但是引用连追溯不到
GC Roots
。 那么固定可作为
GC Roots
的对象包括以下几种:
名词解释:
将内存按照容量划分为大小相等的A、B两个区域,每次只是用一个区域。当A区域内存用完,将存活着的对象复制到B区域,然后清空A区,这样每次都是对半进行内存回收。 特点:
复制回收算法
将堆内存的新生代划分为较大的Eden区和较小的两个一样大小的Survivor区(From、To),他们的比例在前文的图中已经说明是8:1:1,因为绝大部分的对象的生命很短暂,幸存区没必要设置太大。
此算法分为两个阶段: 标记
和 清除
可达性分析算法
特点:
内存碎片
标记-清除
算法如上图所示,如果此时一个大对象(需要连续内存)需要分配,虽然堆内存总的空间是够用的,但是并没有连续的可用空间导致大对象放不下,提前造成GC。
此算法分为两步:标记和整理
可达性分析算法
特点:
因为涉及到对象的移动,改变对象的引用地址,所以在整理的时候需要暂停用户线程,加重系统负担,目前此算法用户老年代的回收算法支持。
JVM最早的垃圾收集器,串行化执行模式,它是一个单线程的收集器,适合内从只有几十到一两百兆的堆空间进行回收。因为它是单线程进行垃圾回收所以用户线程停顿十分明显,目前JDK默认已经没有使用此收集器,但是可以使用-XX+UseSerialGCz指定开启串行收集器。Serial使用 复制回收算法
进行新生代回收,Serial Old使用 标记-整理算法
进行老年代回收工作。
从JDK1.3开始,JVM就采用了多线程的垃圾回收机制,Parallel收集器更关注系统的吞吐量,能有效的利用CPU时间尽快完成垃圾回收任务,此收集器使用户几百到几千兆的堆内存空间进行回收;可以通过-XX+UseParallelGC开启此收集器,JDK1.8默认使用此垃圾收集器。Parallel Scavenge使用 复制回收算法
进行新生代回收,Parallel Old使用 标记-整理算法
进行老年代回收工作。
实质上是Serial收集器的多线程版本,它只负责新生代的垃圾回收,根据上文图中的垃圾收集器分配图来看,它一般配合Serial和CMS配合使用。在JDK9以后将其合并到CMS中。
CMS收集器是一款追求最短回收停顿时间为目标的收集器。比较特殊的是,CMS收集器是回收老年代,而且是基于 标记-清除算法
实现,他的整个工作过程分为4步:
我们可以使用-XX:UseConcmarkSweepGC开启CMS老年代和ParNew新生代的回收器。 CMS收集器是一个具有划时代意义的垃圾收集器,但是它也有缺点:
标记-清除算法
进行回收的收集器,所以会产生内存碎片问题。 之所以CMS采用 标记-清除算法
是因为如果采用 标记-整理算法
会涉及到对象内存地址的移动,此时需要暂停用户线程来修改栈中的地址,这和CMS设计的初衷不符。
有一个划时代意义的收集器,因为传统的垃圾收集器仍然对线程暂停的时间不可预测,为了实现线程暂停的时间变为可预测,G1收集器将堆内存做了较大改变,将其划分为大小相等且独立的区域Region,每一个Region都可以根据需要扮演Eden、Survivor、old。Region中有一类特殊的Humongous区域,专门用来存储大对象,只要对象大小超过一个Region容量的一般即判定为大对象,如果对象更大将会放在多个连续的Humongous中,Humongous默认当做老年代来看待。我们可以通过-XX:+UseG1GC来开启使用G1收集器。G1新生代采用 复制算法
,老年代使用 标记-整理算法
G1的过程分为以下几步:
TAMS
G1收集器的特点: