Java虚拟机是Java的核心和基础,他是Java编译器和操作系统平台之间处理器,能实现跨平台运行Java程序。本文主要讲解的是虚拟机如何管理对象,即Java对象在JVM虚拟机中被创建到回收的流程
Java对象从创建到回收的生命周期
- 对象创建流程
- 1.类加载检查
- 类加载过程
- 2.分配内存
- JVM运行时内存数据区布局
- 运行时数据区关系
- 3.初始化
- 4.设置对象头
- 5.执行init方法
- 对象回收流程
- 1.标记存活对象
- 2.非存活对象回收
- 垃圾回收策略
- 垃圾回收算法
- 垃圾回收器
- Serial收集器
- Parallel Scavenge收集器
- ParNew收集器
- CMS
对象创建流程
public class Main {
public static void main(String[] args) {
createObject();
}
public static void createObject() {
Object object = new Object();
}
}
当虚拟机碰到new或者克隆一个类的实例对象时【如上new Object()】,它会经历如下过程:
1.类加载检查
当虚拟机遇到一个new指令的时候,首先去判断这个指令的参数是否能在方法区找到类的引用,并且检查这个引用是否已经被加载,解析和初始化过。如果没有,则必须先执行先执行类的加载过程
类加载过程
参考博客:Java类的加载过程
2.分配内存
当类加载完以后,虚拟机将会为新生对象分配内存。对象所需的内存大小在类加载完后便可完全确定,便会将Java堆中一块确定大小的内存从Java堆中划分出来
此时会涉及两个问题
2.1.虚拟机如何分配内存
2.2.在并发情况下,如何保证多个对象的内存不重叠
JVM运行时内存数据区布局
a)堆区
Java堆是用来存储对象本身的以及数组。堆是被所有线程共享的,在JVM中只有一个堆。堆有分代逻辑,堆划分为了年轻代,老年代;年轻代分为了eden区,s0区,s1区,比例默认为老年代:年轻代=2:1,eden:s0:s1=8:1:1;
这里引发两个问题:
1.为什么堆中要分代?
2.为什么年轻代还要分为3个区?为什么区的比例为8:1:1?
会涉及到关于对象回收的机制:
1.堆中分代是为了提高效率,对象是有生命周期,有的对象存活时间长,有的对象存活时间短,存活时间长的放到老年代;存活时间短的放到年轻代。老年代回收频率低点,年轻代回收频率高点
2.分为3个区是为了gc之后方便把存活的对象复制到s1或者s2中,方便内存整理。比如为8:1:1是因为大多数的对象都是朝生夕死的,存活时间比较短,所以jvm默认8:1:1的比例是合适的,让 Eden区尽量大,survivor区足够用即可
b)栈
Java栈中存放一个个栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括:局部变量表,操作栈,动态链接【指向当前方法所属的类的运行时常量池的方法】,方法出口【方法执行完后需返回地址】
c)本地方法栈
本地方法栈与Java栈作用和原理类似,区别在于Java栈是为执行本地方法服务的,而本地方法栈则是执行本地方法服务的
d)方法区
与堆一样,是线程共享的区域。在方法区中存储了类信息,静态变量,常量以及编译器编译后的代码
e)程序计数器
在jvm中,多线程是通过线程轮流切换来获得cpu执行时间的。为了保证每个线程在切换后能够恢复到切换之前程序执行的位置,每个线程都有自己独立的程序计数器
运行时数据区关系
由下图可以看出:堆和方法区是所有线程共用的,本地方法栈,Java栈,程序计数器是线程私有的
看到堆中存放对象,引发两个问题:
1.所有新创建对象都存放到堆中吗?
2.堆中既有老年代又有年轻代,新创建的对象应该放在哪里
我们先整体看一下对象在内存的分配流程,然后再逐一回答上面的问题
根据图流程所示回答第一个不是所有的对象都会存放到堆中,有可能是栈。栈上分配主要是虚拟机根据该对象是否被外部访问。如果不会逃逸出该对象在栈上分配,随着方法的结束出栈而销毁。
对象逃逸分析:分析对象的动态作用域,当一个对象在方法中被定义后,它可能被外部方法引用,例如被作为参数传递到其他地方去
public User createUser(){
User user = new User();
user.setName("judy");
user.setAge(18);
return user;
}
public void test1(){
User user = createUser();
}
像上面的方法,它可能被其他方法调用,无法在栈内分配;而像作用域非常确定,比如:
public void createUser(){
User user = new User();
user.setName("judy");
user.setAge(18);
}
那这种作用域非常确定,不会被其他方法调用,就可以在栈内分配。
因为栈内存比较小,为了防止栈内没有一大块连续空间导致对象内存不够分配,虚拟机会采用标量替换方法去解决这个问题
标量替换:当对象确定在栈内分配时候,并且开启了标量替换,它不会直接创建对象,因为创建对象需要设置对象头也会占用空间,而是将该Java对象成员变量进行分解,分为若干个被这个方法使用的局部成员变量,然后为分解后的变量分配空间。jdk7以后默认开启。标量一般是指Java中的基本数据类型
解决完第一个问题:对象否则都存在于堆中?我们再来看第二个问题:堆中既有老年代又有年轻代,新创建的对象应该放在哪里?
第二个问题的答案是:一个新创建的对象,如果非常大,超过虚拟机设置的值,就会直接放到老年代;但是如果没有超过限定值,先放到Eden中。至于S0,S1区,对象在通过minorGC以后才会存放到这两个区域中,后续讲解到对象的垃圾回收时,还会重点解决一下。
3.初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值【不包括对象头】。这一步保证对象的实例字段在Java代码中可以不赋初始值就直接使用,程序就能访问到这些字段的数据类型所对应的零值
4.设置对象头
对对象的实例属性值设置为零值后,需要对对象进行必要的设置,例如这个对象属于哪个类的实例,如果才能找到类的元数据信息,对象的哈希码,对象gc分代信息。这些信息在对象的对象头中。
对象分为三部分信息:对象头,实例数据,对象填充
4.1对象头:分为三部分信息
4.1.1第一部分存储对象自身运行时的数据
4.1.2第二部分存储类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象属于哪个类的实
4.1.3第三部分是数组长度,若为数组则有数,若没有则为空
4.2实例数据:对象的实例属性
4.3对象填充:当对象头+实例数据的字节数不是8字节的整数时,字节填充使其为8字节的整数倍,加快内存寻址
5.执行init方法
为对象赋值和执行构造方法
对象回收流程
1.标记存活对象
对象回收之前,先判断对象是否活着,判断对象是否活着两种方式
1.引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;
当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
2.可达性分析算法
将"GC Roots"对象作为起点,从这些起点开始向下搜索引用的对象,找到的对象是非垃圾的对象,其余标记为垃圾对象。
GC Roots根节点:线程栈的本地变量,静态变量,本地方法栈中的变量
其中引用关系可以分为:强引用,软引用,弱引用,虚引用
强引用:普通的变量引用
public static User user=new User();
软引用:被SoftReference对象包裹着,正常情况不会被回收,但是gc做完后发现释放不出空间存放新的对象,则会将这些软引用对象回收掉。用来实现内存敏感的高速缓存
public static SoftReference<User> user=new SoftReference<User>(new User());
弱引用:将对象用WeakReference软引用类型的对象包裹着,弱引用跟没有引用差不多,GC会直接回收掉。
虚引用:最弱的引用,几乎不用
引发一个问题,是否没有可达性分析分析到的对象就会被回收?
答案是不会,一个对象被回收要经历两次标记的过程
1.第一次标记进行筛选
筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,对象将直接被回收。
2.第二次标记
第二次标记如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救
自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,
那在第二次标记时它将移除出“即将回收”的集合。
如果对象这时候还没逃脱,那基本上它就真的被回收了。
注意:
一个对象的finalize()方法只会被执行一次,
也就是说通过调用finalize方法自我救命的机会就一次。
2.非存活对象回收
被标记的对象是如何在内存中被回收,首先堆在内存是分代的,两者根据对象生命周期的不同,存放着不同类型的对象。那意味着也需要采取不同的回收策略【年轻代回收和老年代回收】,回收策略也有着相应的回收算法,根据不同垃圾回收算法JVM也提供了一系列垃圾回收器
垃圾回收策略
Minor GC/Yong GC:指发生新生代的垃圾收集动作,Minor GC会非常的频繁,回收速度一般比较快;
Major GC/Full GC:一般会回收老年代,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC慢
垃圾回收算法
-
标记复制
将内存分为大小相同的两块,每次使用其中的一块。当一块用完后,将存活的对象复制到另一块去,然后把使用的空间一次清理掉。
-
标记清除
算法分为标记和清除阶段:标记存活的对象,统一回收所有未被标记的对象;也可以标记需要回收的对象,在标记完成后统一回收被标记的对象。它是最基础的手机算法,比较简单,但是会带来1个问题:清除后产生大量不连续的碎片
-
标记整理
根据老年代特有的标记算法,标记过程和标记-清除算法一样,但后续步骤不是直接对可回收的对象回收,而是让所有存活的对象向一端移动,然后直接清理掉边界以外的内存。
垃圾回收器
垃圾回收算法是内存分配的方法论,那么垃圾回收器是内存分配的具体实现
Serial收集器
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。
大家看名字就知道这个收集器是一个单线程收集器了。它的“单线程”的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(“StopTheWorld”),直到它收集结束。新生代采用复制算法,老年代采用标记-整理算法。
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。可以和CMS收集器搭配使用
配置参数如下:
-XX:+UseSerialGC
-XX:+UseSerialOldGC
Parallel Scavenge收集器
Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,
其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。
默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)
CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)
新生代采用复制算法,老年代采用标记-整理算法。
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。
使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑
ParallelScavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。
参数配置:
-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代)
ParNew收集器
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
新生代采用复制算法,老年代采用标记-整理算法。
参数配置:
-XX:+UseParNewGC
CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从名字中的MarkSweep这两个词可以看出,CMS收集器是一种“标记-清除”算法实现的,它的运作过程相比于前面
几种垃圾收集器来说更加复杂一些。
整个过程分为四个步骤:
初始标记:
暂停所有的其他线程(STW),并记录下gcroots直接能引用的对象,速度很快。
并发标记:
并发标记阶段就是从GCRoots的直接关联对象开始遍历整个对象图的过程,
这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
重新标记:
重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法做重新标记。
并发清理:开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理
并发重置:重置本次GC过程中的标记数据。
参数配置:
-XX:+UseConcMarkSweepGC(old)
Java虚拟机对对象的管理讲解的这里,有问题可以留言喔