OK,前边有说过JVM的模块都有哪些,方法区、虚拟机栈、本地方法栈、程序计数器、还有我们下面要说的堆,那么到此时,这些模块还都是相互独立的,我们需要把这些模块都串联起来,这样才能更系统的了解JVM,这就是今天要说的对象的创建过程。
首先我们先看个图,然后根据图来阐述这个对象的创建过程:
类加载
首先就是类加载,类加载就是把class 加载到JVM 的运行时数据区的过程-->静态常量池转化成运行时常量池(类加载这个以后专门讲类加载器的时候再讲)。
检查加载
然后就是检查加载,虚拟机遇到一条new 指令时,检查是否能在常量池中定位到一个类的符号引用,检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。
分配内存
然后就是重头戏,分配内存!分配内存就是在对象创建时,首先需要在堆内划分一块内存空间,用来存储对象的实例。分配内存有两种方式,一个是指针碰撞,一个是空闲列表。
指针碰撞:如果Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。就像下图一样,规整的内存空间,直接移动内存指针就可以分配空间。
空闲列表:如果Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。如下图,已分配和未分配的内存空间是犬牙交错的,所以要维护一个可用内存表,然后在为对象分配内存空间时在表中查找足够大的一块的内存划分给对象实例,
选择哪种方式,是jvm根据堆空间是否整齐来自行判断的,而堆空间是否整齐又是依据垃圾回收器是否有压缩整理功能来决定的。除了CMS这个垃圾回收器由于采用标记清除法而使得内存空间不整齐外,其他的使用标记整理或者复制算法的垃圾回收器都可以保证内存空间整齐的。(具体的垃圾回收器下个章节再说)
那么在划分空间的时候,又会引发一个问题,就是并发安全问题,因为java天生是多线程,这就会造成,在给对象A分配内存的同时,可能也会在给对象B分配内存,如果给这两个对象同时分配同一块内存,就炸了锅了。那么为了解决这个问题,JVM有两种解决方式,一个就是采用CAS方式进行内存分配,实际上虚拟机采用CAS 配上失败重试的方式保证更新操作的原子性;第二种就是采用分配缓冲方式(Thread Local Allocation Buffer TLAB)进行内存分配,虚拟机参数 -XX:UseTLAB就是代表是否开启TLAB分配,默认是开启的,且为每个线程在Eden区分配一个大小为Eden区1%大小的区域,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小分配对象的时候优先在缓冲区划分内存空间。这样每个线程在分配对象到TLAB时,就可以减小同步开销,注意,TLAB只是在为对象分配内存时是私有的,但是这个对象实例本身是在Eden区的,它是一个线程共享的对象。如果分配的对象太大,已经超过TLAB这块区域了,则会采用CAS方式在Eden区进行分配,TLAB本身也是可以扩展的,如果一个一个创建许多个小对象,JVM也是可以继续分配一个新的TLAB区域的。TLAB的缺点是会造成内存浪费还有内存零散化。
内存空间初始化
(注意不是构造方法)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如int 值为0,boolean 值为false 等等)。这一步操作保证了对象的实例字段在Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值,这步也称为对象的半初始化。
设置
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes 在Java hotspot VM 内部表示为类元数据)、对象的哈希码、对象的GC 分代年龄等信息。这些信息存放在对象的对象头之中。
对象的初始化
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,一般来说,执行new 指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。也就是说我们开发时常说的初始化对象,其实就是整个对象创建过程的最后一步。
对象的内存布局
在HotSpot 虚拟机中,对象在内存中存储的布局可以分为3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(MarkWord),如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个java 数组,那么在对象头中还有一块用于记录数组长度的数据。第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM 的自动内存管理系统要求对对象的大小必须是8 字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
现在对象创建完了,那么如何使用的,我们都知道,我们使用对象,是通过一个引用来获取存放在堆中的对象,那么JVM是有两种引用方式去完成引用操作-句柄池引用和直接引用。
句柄
如果使用句柄访问的话,那么Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄来访问的最大好处就是reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference 本身不需要修改。
直接指针
如果使用直接指针访问, reference 中存储的直接就是对象地址。这两种对象访问方式各有优势,使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。对Sun HotSpot 而言,它是使用直接指针访问方式进行对象访问的。
判断对象存活
在堆内存中存放许许多多的实例对象,但是这些对象不是每个都是有效对象,其中一大部分可能都是程序中已经废弃不用的,那么就需要JVM把这些对象使用垃圾回收器进行回收,那么如何确定对象是否有效(存活),就需要JVM去遍历整个堆内存按照某种算法去寻找标记对象是否存活。那么这种算法就是引用计数法和可达性分析算法。
引用计数法:引用计数法是jvm早期使用的一种判断对象是否存活的算法,意思就是只有有一个对此对象的引用,那么这个对象的引用次数就加1,断开这个引用就减1,然后把所有数量为0的对象标记为死亡对象。但是这种算法有个致命性缺点,就是相互引用问题:
public class Demo
{
public Object instance = null;
public static void main(String[] args) {
Demo a = new Demo();
Demo b = new Demo();
/**
* 导致对象见相互引用,此时引用计数器法失效
*/
a.instance = b;
b.instance = a;
a = null;
b = null;
}
}
如上述代码,线程首先创建了两个对象A和B,并指向了a和b的两个引用,然后给对象A的instance赋值B,B的instance赋值A,此时A和B对象本身的引用次数是2,一个是a的引用,一个是instance的引用,此时把a和b的引用置空,意思就是线程用不到这两个对象了,那么两个对象的引用次数减1,还有一个引用,引用计数法认为这个对象还是一个存活对象,但其实这两个对象在任何情况下已经处于不可达状态了,只是俩对象间相依为命,这就造成了内存泄漏的问题,本该回收的对象没有被回收掉。
所以为了解决引用计数法的这种问题,就有了可达性分析算法:这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots 没有任何引用链相连时,则证明此对象是不可用的。
作为GCRoots的对象
1.虚拟机栈的引用的对象。
2.本地方法栈(native)的引用对象。
3.方法区的常量引用的对象(例如字符串常量池)。
4.方法区中类静态引用。
5.JVM 的内部引用(class 对象、异常对象NullPointException、OutofMemoryError,系统类加载器)。(非重点)
6.所有被同步锁(synchronized 关键)持有的对象。(非重点)
7. JVM 内部的JMXBean、JVMTI 中注册的回调、本地代码缓存等(非重点)
8. JVM 实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象,这个后续会细讲,先大致了解概念)(非重点)
对象的引用
在JVM中,对象的引用关系从强到弱有四种,分别是强引用、软引用、弱引用和虚引用。
强引用:我们在开发中用的像Object obj = new Object();这种等号相连的,叫做强引用,只要还处于根可达状态,就不会被GC回收,大多数内存溢出都是由于这种强引用关系存在导致的。
软引用(SoftReference):一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出)。
//SoftReference By wangs
public static void main(String[] arg0)
{
SoftReference<Map> reference = new SoftReference<Map>(new HashMap());
reference.get().put("name", "王小汪");
//执行GC
System.gc();
//打印软引用的印象
System.out.println(reference.get());
}
//SoftReference OOM By wangs
public static void main(String[] arg0)
{
SoftReference<Map> reference = new SoftReference<Map>(new HashMap());
reference.get().put("name", "王小汪");
System.out.println("常规:"+reference.get());
List list = new ArrayList();
try
{
for(int i = 0;i<Integer.MAX_VALUE;i++)
{
//循环在堆中创建一个10M的对象,且是强引用
list.add(new byte[1024*1024*10]);
}
}
catch(Exception e)
{
//发生OOM异常
e.printStackTrace();
}
finally
{
System.out.println("OOM溢出:"+reference.get());
}
}
结果1:
SoftReference 常规示例---------
常规:{name=王小汪}
结果2:
SoftReference OOM示例---------
常规:{name=王小汪}
OOM溢出:null
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at ReferenceDemo.main(ReferenceDemo.java:21)
由上边的结果可知,在堆内存够用的情况下,GC回收是回收不了软引用持有的对象的,但是如果堆内存不够了,马上就要OOM了,软引用持有的对象就会被GC回收掉来释放内存,如果还不够,才会抛出OOM异常。
弱引用(WeakReference):一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。 弱引用和软引用的区别就在于,经过一次GC后,只持有弱引用的对象必然被回收,而持有软引用的对象则一定会被回收,除非马上就要OOM了。弱引用在JDK中还有一个比较好的例子,就是在ThreadLocal中的使用,有兴趣的可以看一下我得另外一篇博客:https://mp.csdn.net/console/editor/html/107036756
//WeakReference By wangs
public static void main(String[] arg0)
{
WeakReference<Map> reference = new WeakReference<Map>(new HashMap());
reference.get().put("name", "王小汪");
System.out.println("JVM进行GC前:"+reference.get());
System.gc();
System.out.println("JVM进行GC后:"+reference.get());
}
//结果
//JVM进行GC前:{name=王小汪}
//JVM进行GC后:null
弱引用其实理解起来就比较简单了,就是只要发生GC回收,不管内存还够不够用,我都要把弱引用持有的对象回收掉。
虚引用(PhantomReference):这种引用在开发中基本用不到,了解一下就行,就是为了监控垃圾回收器是否正常工作用的。
对象的分配策略
什么叫对象的分配策略?对象的分配策略说白了就是按照某种规则,将对象放在某块合适的内存区域。
一、对象创建优先在Eden区分配
Java在分配对象的时候,会优先在Eden区分配,Eden区是新生代中一个部分,我会在下一个章节垃圾回收时详细讲。
二、空间分配担保
在发生Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC 是有风险的,如果担保失败则会进行一次Full GC;如果小于,或者HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次Full GC。OK,最简单的一句话来说就是老年代在新生代发生GC前跟新生代保证,不论你垃圾回收后剩余多少对象,我都能接收,这就是空间分配担保。为什么会有担保呢?其实就是考虑到新生代每次minorGC完,都要担心老年代没地儿放新生代晋升老年代的对象,所以就需要强制老年代进行垃圾回收,这样就会造成频繁的FullGC,程序基本就完蛋了。
三、大对象直接进入老年代
大对象就是指需要大量连续内存空间的Java 对象,界定大对象大小是根据不同垃圾回收器来说的,JDK1.8默认的垃圾回收器是Parallel Scavenge,它是不需要设置的,在开发中,尽量避免大对象的产生,因为这可能会导致频繁发生FullGC,不利于系统的运行。下面我通过代码来实际展现一下不同垃圾回收器对于大对象直接进入老年代的解释。
//-Xms200M堆最小空间 -Xmx200M堆最大空间-Xmn100M新生代空间 -XX:+PrintGCDetails
public static void main(String[] arg0)
{
byte[] demo1 = new byte[1024*1024*20];
byte[] demo2 = new byte[1024*1024*20];
byte[] demo3 = new byte[1024*1024*20];
byte[] demo4 = new byte[1024*1024*40];
}
/*Heap
PSYoungGen total 89600K, used 66048K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
eden space 76800K, 86% used [0x00000000f9c00000,0x00000000fdc801d0,0x00000000fe700000)
from space 12800K, 0% used [0x00000000ff380000,0x00000000ff380000,0x0000000100000000)
to space 12800K, 0% used [0x00000000fe700000,0x00000000fe700000,0x00000000ff380000)
ParOldGen total 102400K, used 40960K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
object space 102400K, 40% used [0x00000000f3800000,0x00000000f6000010,0x00000000f9c00000)
Metaspace used 2745K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 294K, capacity 386K, committed 512K, reserved 1048576K*/
如图,我是jdk1.8,默认就是Parallel Scavenge回收器,可以看到,Eden区现在是80M,demo1/demo2/demo3已经分配给了Eden区,此时Eden区可用大小是20M(不考虑实际损耗,实际总大小是75M),那么此时分配一个40M的对象进来,JVM一看当前Eden区已经放不下这个对象了,且大小>=1/2Eden,所以直接分配到老年代。
//-Xms200M -Xmx200M -Xmn100M -XX:+PrintGCDetails -XX:+UseParNewGC -XX:PretenureSizeThreshold=4M
public static void main(String[] arg0)
{
byte[] demo1 = new byte[1024*1024*1];
}
/*Heap
par new generation total 92160K, used 5939K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
eden space 81920K, 7% used [0x00000000f3800000, 0x00000000f3dcce78, 0x00000000f8800000)
from space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
to space 10240K, 0% used [0x00000000f9200000, 0x00000000f9200000, 0x00000000f9c00000)
tenured generation total 102400K, used 0K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
the space 102400K, 0% used [0x00000000f9c00000, 0x00000000f9c00000, 0x00000000f9c00200, 0x0000000100000000)
Metaspace used 2745K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 294K, capacity 386K, committed 512K, reserved 1048576K
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release
*/
现在我使用ParNew垃圾回收器,然后设置-XX:PretenureSizeThreshold参数标明大于4M的对象为大对象,此时我创建了一个1M的对象,现在是优先分配到Eden区(7%中对象只有1%,其他的是损耗,不考虑)
//-Xms200M -Xmx200M -Xmn100M -XX:+PrintGCDetails -XX:+UseParNewGC -XX:PretenureSizeThreshold=4M
public static void main(String[] arg0)
{
byte[] demo1 = new byte[1024*1024*5];
}
/*Heap
par new generation total 92160K, used 4915K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
eden space 81920K, 6% used [0x00000000f3800000, 0x00000000f3ccce68, 0x00000000f8800000)
from space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
to space 10240K, 0% used [0x00000000f9200000, 0x00000000f9200000, 0x00000000f9c00000)
tenured generation total 102400K, used 5120K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
the space 102400K, 5% used [0x00000000f9c00000, 0x00000000fa100010, 0x00000000fa100200, 0x0000000100000000)
Metaspace used 2745K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 294K, capacity 386K, committed 512K, reserved 1048576K
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release
*/
然后我又创建了一个5M的对象,注意看,此时Eden并没有被使用,这5M的对象直接就被放到了老年代,这就是和Parallel Scavenge的一个区别。如果-XX:PretenureSizeThreshold=4M被去掉了,对于ParNew垃圾回收器来说就不存在大对象这么一回事儿了,只是单纯的Eden区优先分配了,这个我就不贴图了,可以自己去了解下。
四、长期存活的对象进入老年代
一个对象,在创建时一般会在Eden区生成,如果这个对象是一个长期存活的对象,那么这个对象会晋升到老年代,那么什么叫长期存活呢?一般垃圾回收器默认经过15次minorGC还存在的对象,就认为它是一个长期存活对象,CMS垃圾回收器默认是经过6次。当然也可以通过-XX:MaxTenuringThreshold 来调整存活次数。
五、动态对象年龄判断
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold 才能晋升老年代,如果在Survivor 空间中相同年龄所有对象大小的总和大于Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold 中要求的年龄。
//-Xms200M堆最小空间 -Xmx200M堆最大空间-Xmn100M新生代空间 -XX:+PrintGCDetails
public static void main(String[] arg0)
{
byte[] demo1 = new byte[1024*1024*20];
byte[] demo2 = new byte[1024*1024*20];
byte[] demo3 = new byte[1024*1024*20];
byte[] demo4 = new byte[1024*1024*10];
byte[] demo5 = new byte[1024*1024*30];
}
/*[GC (Allocation Failure) [PSYoungGen: 74752K->10888K(89600K)] 74752K->72336K(192000K), 0.0419769 secs] [Times: user=0.13 sys=0.06, real=0.04 secs]
[Full GC (Ergonomics) [PSYoungGen: 10888K->0K(89600K)] [ParOldGen: 61448K->72242K(102400K)] 72336K->72242K(192000K), [Metaspace: 2739K->2739K(1056768K)], 0.0184298 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
Heap
PSYoungGen total 89600K, used 31488K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
eden space 76800K, 41% used [0x00000000f9c00000,0x00000000fbac0188,0x00000000fe700000)
from space 12800K, 0% used [0x00000000fe700000,0x00000000fe700000,0x00000000ff380000)
to space 12800K, 0% used [0x00000000ff380000,0x00000000ff380000,0x0000000100000000)
ParOldGen total 102400K, used 72242K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
object space 102400K, 70% used [0x00000000f3800000,0x00000000f7e8c8d8,0x00000000f9c00000)
Metaspace used 2746K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 294K, capacity 386K, committed 512K, reserved 1048576K*/
如图,首先Eden区已经分配了70M的空间,那么此时有个30M的对象要创建,且这个对象小于Eden的一半(Parallel Scavenge),那么就会经过一次minorGC,那么第二次FullGC是因为空间分配担保,因为垃圾回收器在晋升对象老年代后,会判断老年代剩余空间是否满足历次晋升的条件,那么这个历次晋升其实就是刚刚那次晋升,因为只发生了一次,所以老年代还剩余30M空间,历次晋升是70M,不够,那么就发生一次FullGC。垃圾回收完之后,新的对象会继续创建在Eden区,而前几个对象其实还没有到达MaxTenuringThreshold次数,但是实际上已经晋升到了老年代。
虚拟机的优化技术
前边我们讲了对象的创建以及如何在堆空间分配,现在我们来说一下JVM在对象创建时,对于这个过程的优化。
一、栈上分配
还记得我反复说过的,“几乎所有”的对象都在堆中这句话么?其实有一小部分的对象是可以在虚拟机栈上进行分配的,好处是栈上分配的对象,会随着线程的死亡而自动清除,不需要垃圾回收器进行回收,这对性能的提升还是很大的。那么如何判断一个对象是否能够栈上分配呢?这就需要请出逃逸分析这个算法。
逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用,这就叫做线程可逃逸。
比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM 的效率。
是否开启逃逸分析功能的参数是-XX:+DoEscapeAnalysis,JVM默认是开启的,一般不要动它就好。
二、TLAB分配
TLAB分配我们在上边的分配内存的章节已经讲过了,这里就不再重新赘述了,但是记住它也是优化技术之一,因为它能够降低分配内存时竞争内存的开销,但是它本质也是分配到堆内存的,也是要经过垃圾回收器进行回收的。
对象分配空间的顺序
这就是对象在创建时如何进行内存分配的模型图,首先判断这个对象是否可以进行栈上分配,通过逃逸分析来判断,如果不满足,则判断是否满足TLAB分配,如果还不满足,就判断这个对象是否为大对象(不同垃圾回收器判断方式不一样),如果不是大对象,就在Eden区分配,如果是大对象,就分配到老年代,同时新生代中满足晋升条件的对象可以晋升到老年代。