牛批!终于有人把JVM内存分配机制讲明白了!超详细解析!
2021/10/19 7:09:57
本文主要是介绍牛批!终于有人把JVM内存分配机制讲明白了!超详细解析!,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
一、对象的加载过程
那么,当一个象被new的时候,是如何加载的呢?有哪些步骤,如何分配内存空间的呢?
1.1 对象创建的主要流程
还是这段代码为例说明:
public static void main(String[] args) { Math math = new Math(); math.compute(); new Thread().start(); }
当我们new一个Math对象的时候,其实是执行了一个new指令创建对象。我们之前研究过类加载的流程,那么创建一个对象的流程是怎样的呢?如下图所示。下面我们一个环节一个环节的分析。
1.1.1类加载检查
当虚拟机执行到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个
符号引用代表的类是否已经被加载、解析和初始化过(也就是检查类是否已经被加载过)。如果没有,那必须先执行相应的类加载流程。
1.1.2分配内存空间
类加载检查通过以后,接下来就是给new的这个对象分配内存空间。对象需要多大内存是在类加载的时候就已经确定了的。为对象分配空间的过程就是从java堆中划分出一块确定大小的内存给到这个对象。那么到底如何划分内存呢?如果存在并发,多个对象同时都想占用同一块内存该如何处理呢?
1)如何给对象划分内存空间?
通常,给对象分配内存有两种方式:一种是指针碰撞,另一种是空闲列表。
- 指针碰撞
指针碰撞(Bump the Pointer),默认采用的是指针碰撞的方式。如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
- 空闲列表
如果Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
不同的内存分配方式,在垃圾回收的时候采用不同的方法。
2)如何解决多个对象并发占用空间的问题?
当有多个线程同时启动的时候,多个线程new的对象都要分配内存,不管内存分配使用的是哪种方式,指针碰撞也好,空闲列表也好,这些对象都要去争抢这块内存。当多个线程都想争抢某一块内存的时候,这时该如何处理呢?通常有两种方式:CAS和本地线程分配缓冲。
- CAS(compare and swap)
CAS可以理解为多个线程同时去争抢一个快内存,抢到了的就使用,没抢到的就重试去抢下一块内存。
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
- 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
什么是TLAB呢?简单说,TLAB是为了避免多线程争抢内存,在每个线程初始化的时候,就在堆空间中为线程分配一块专属的内存。自己线程的对象就往自己专属的那块内存存放就可以了。这样多个线程之间就不会去哄抢同一块内存了。jdk8默认使用的就是TLAB的方式分配内存。
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过-XX:+UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
1.1.3 初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也
可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问
到这些字段的数据类型所对应的零值。
1.1.4 设置对象头
我们来看看这个类:
public class Math { public static int initData = 666; public static User user = new User(); public int compute() { int a = 1; int b = 2; int c = (a + b) * 10; return c; } public static void main(String[] args) { Math math = new Math(); math.compute(); new Thread().start(); } }
对于一个类,通常我们看到的是成员变量和方法,但并不是说一个类的信息只有我们目光所及的这些内容。在对象初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header中。 在HotSpot虚拟机中,对象在内存中包含3个部分:
- 对象头(Header)
- 实例数据(Instance Data)
- 对象填充(Padding)
实例数据就不多说了,就是我们经常看到的并使用的数据。对象头和填充数据下面我们重点研究。先来说对象头。
1. 对象头的组成部分
HotSpot虚拟机的对象头包括以下几部分信息:
第一部分:Mark Word标记字段,32位占4个字节,64位占8个字节。用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
第二部分:Klass Pointer类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 开启压缩占4个字节,关闭压缩占8个字节。
第三部分:数组长度,通常是4字节,只有对象数组才有。
2.Mark Word标记字段
如下图所示是一个32位机器的对象头的mark word标记字段。对象不同的状态对应的对象头的结构也是不一样的。根据锁状态划分对象有5种状态,分别是:无状态、轻量级锁、重量级锁、GC标记、偏向锁。
无锁状态,就是普通对象的状态。一个对象被new出来以后,没有任何的加锁标记,这时候他的对象头分配是
- 25位:用来存储对象的hashcode
- 4位:用来存储分代年龄。之前说过一个新生对象的年龄超过15还没有被回收就会被放入到老年代。为什么年龄设置为15呢?因为分代年龄用4个字节存储,最大就是15了。
- 1位:存储是否是偏向锁
- 2位:存储锁标志位
最后这两个就和并发编程有关系了,后面我们会重点研究并发编程的时候研究这一块。
3.Klass Pointer类型指针
在64位机器下,类型指针占8个字节,但是当开启压缩以后,占4个字节
一个对象new出来以后是被放在堆里的,类的元数据信息是放在方法区里的,在new对象的头部有一个指针指向方法区中该类的元数据信息。这个头部的指针就是Klass Pointer。而当代码执行到math.compute()方法调用的时候,是怎么找到compute()方法的呢?实际上就是通过类型指针去找到的。(知道了math指向的对象的地址,再根据对象的类型指针找到方法区中的源代码数据,再从源代码数据中找到compute()方法)。
public static void main(String[] args) { Math math = new Math(); math.compute(); }
对于Math类来说,他还有一个类对象, 如下代码所示:
Class<? extends Math> mathClass = math.getClass();
这个类对象是存储在哪里的呢?这个类对象是方法区中的元数据对象么?不是的。这个类对象实际上是jvm虚拟机在堆中创建的一块和方法区中源代码相似的信息。如下图堆空间右上角。
那么在堆中的类对象和在方法区中的类元对象有什么区别呢?
类的元数据信息是放在方法区的。堆中的类信息,可以理解为是类装载后jvm给java开发人员提供的方便的访问类的信息。通过类的反射我们知道,我们可以通过Math的class拿到这个类的名称,方法,属性,继承关系,接口等等。我们知道jvm的大部分实现是通过c++实现的,jvm在拿到Math类的时候,他不会通过堆中的类信息(上图堆右上角math类信息)拿到,而是直接通过类型指针找到方法区中元数据实现的,这块类型指针也是c++实现的。在方法区中的类元数据信息都是c++获取实现的。而我们java开发人员要想获得类元数据信息是通过堆中的类信息获得的。堆中的class类是不会存储元数据信息的。我们可以吧堆中的类信息理解为是方法区中类元数据信息的一个镜像。
Klass Pointer类型指针的含义:Klass不是class,class pointer是类的指针;而Klass Pointer指的是底层c++对应的类的指针
4.数组长度
如果一个对象是数组的话,除了Mark Word标记字段和Klass Pointer类型指针意外,还会有一个数组长度。用来记录数组的长度,通常占4个字节。
对象头在hotspot的C++源码里的注释如下:
5.对象对齐(Object alignment)
我们上面说了对象有三块:对象头,实体,对象对齐。那么什么是对象对齐呢?
对于一个对象来说,有的时候有对象对齐,有的时候没有。JVM内部会将对象的读取信息按照8个字节对齐。至于为什么要按8个字节对齐呢?这是计算机底层原理了,经过大量的实践证明,对象按照8个字节读取效率会非常高。也就是说,最后要求字节数是8的整数倍。可以是8,16,24,32.
6.代码查看对象结构
如何查看对象的内部结构和大小呢?我们可以通过引用jol-core包,然后调用里面的几个方法即可查看
引入jar包
引入jar包: <dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.9</version> </dependency>
测试代码
import org.openjdk.jol.info.ClassLayout; /** * 查询类的内部结构和大小 */ public class JOLTest { public static void main(String[] args) { ClassLayout layout = ClassLayout.parseInstance(new Object()); System.out.println(layout.toPrintable()); System.out.println(); ClassLayout layout1 = ClassLayout.parseInstance(new int[]{}); System.out.println(layout1.toPrintable()); System.out.println(); ClassLayout layout2 = ClassLayout.parseInstance(new Object()); System.out.println(layout2.toPrintable()); } class A { int id; String name; byte b; Object o; } }
执行代码运行结果:
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total [I object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 16 0 int [I.<elements> N/A Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total com.lxl.jvm.JOLTest$A object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758) 12 4 int A.id 0 16 1 byte A.b 0 17 3 (alignment/padding gap) 20 4 java.lang.String A.name null 24 4 java.lang.Object A.o null 28 4 (loss due to the next object alignment) Instance size: 32 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
Object对象的内部结构:
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
这里一共有四行:
- 前两行是对象头(Mark Word), 占用8个字节;
- 第三行是Klass Pointer类型指针,占用4个字节,如果不压缩的话会占用8个字节;
- 第四行是Object Alignment对象对齐,对象对齐是为了保证整个对象占用的位数是8的倍数。
数组对象的内部结构
[I object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 16 0 int [I.<elements> N/A Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
这里一共有5行:
- 头两行是Mark word标记字段,占了8位;
- 第三行是Klass Pointer类型指针,占了4位;
- 第四行是数组特有的,标记数组长度的,占了4位。
- 第五行是对象对齐object alignment,由于前面4行一共是16位,所以这里不需要进行补齐
A(自定义)对象的内部结构
com.lxl.jvm.JOLTest$A object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758) 12 4 int A.id 0 16 1 byte A.b 0 17 3 (alignment/padding gap) 20 4 java.lang.String A.name null 24 4 java.lang.Object A.o null 28 4 (loss due to the next object alignment) Instance size: 32 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
这一共有四行:
- 前两行是对象头(Mark Word), 占用8个字节;
- 第三行是Klass Pointer类型指针,占用4个字节,如果不压缩的话会占用8个字节;
- 第四行是int类型 占4位。
- 第五行是byte类型:占1位。
- 第六行是byte补位:步3位。
- 第七行是String类型:占4位
- 第八行是Object类型:占4位
- 第九行是object alignment对象对齐补4位。前面28位,不是8的倍数,所以补4位。
1.1.5.执行方法
这里的init方法,不是构造方法,是c++调用的init方法。执行方法,即对象按照程序员的意愿进行初始化,也就是说真正意义上的为属性赋值(注意,这与上面的初始化赋零值不同,这是赋程序员设置的值),并且调用构造方法。
1.1.6 指针压缩
1. 什么是java对象的指针压缩?
从jdk1.6开始,在64位操作系统中,jvm默认开启指针压缩。指针压缩就是将Klass Pointer类型指针进行压缩,已经Object对象,String对象进行指针压缩。看下面的例子:
import org.openjdk.jol.info.ClassLayout; /** * 查询类的内部结构和大小 */ public class JOLTest { public static void main(String[] args) { System.out.println(); ClassLayout layout2 = ClassLayout.parseInstance(new A()); System.out.println(layout2.toPrintable()); } public static class A { int id; String name; byte b; Object o; } }
运行这段代码,A的类结构:
com.lxl.jvm.JOLTest$A object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758) 12 4 int A.id 0 16 1 byte A.b 0 17 3 (alignment/padding gap) 20 4 java.lang.String A.name null 24 4 java.lang.Object A.o null 28 4 (loss due to the next object alignment) Instance size: 32 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
默认情况下是开启指针压缩的。上面分析过这个类结构,这里主要看第三行Klass Pointer和第七行String占4位,第八行Object占4位。我们知道这里保存的都是指针的地址。
下面我们手动设置关闭指针压缩:
指针压缩的命令有两个:UseCompressedOops(压缩所有的指针对象,包括header头和其他) 和 UseCompressedClassPointers(只压缩指针对象) 开启指针压缩: -XX:+UseCompressedOops(默认开启), 禁止指针压缩: -XX:-UseCompressedOops 参数的含义: compressed:压缩的意思 oop(ordinary object pointer):对象指针
在main方法的VM配置参数中设置XX:-UseCompressedOops
然后再来看运行结果:
com.lxl.jvm.JOLTest$A object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) d0 0c be 26 (11010000 00001100 10111110 00100110) (649989328) 12 4 (object header) 02 00 00 00 (00000010 00000000 00000000 00000000) (2) 16 4 int A.id 0 20 1 byte A.b 0 21 3 (alignment/padding gap) 24 8 java.lang.String A.name null 32 8 java.lang.Object A.o null Instance size: 40 bytes Space losses: 3 bytes internal + 0 bytes external = 3 bytes total
来看变化点:
- Klass Pointer类型指针原来是4位,现在多了4位。类型指针占了8位
- String对象原来占用4位,不压缩是8位
- Object对象原来占用4位,不压缩占用8位
从现象上可以看出压缩和不压缩的区别。那么为什么要进行指针压缩呢?
2.为什么要进行指针压缩?
1.在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据, 占用较大宽带,同时GC也会承受较大压力(占用内存少,可以存储更多对象,触发GC的频率降低)。为了减少64位平台下内存的消耗,默认启用指针压缩功能 。
2.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
3.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
4.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内 存不要大于32G为好.
二、对象的内存分配
对象的内存分配流程如下:
对象创建的过程中会给对象分配内存,分配内存的整体流程如下:
第一步:判断栈上是否有足够的空间。
这里和之前理解有所差别。之前一直都认为new出来的对象都是分配在堆上的,其实不是,在满足一定的条件,会先分配在栈上。那么为什么要在栈上分配?什么时候分配在栈上?分配在栈上的对象如何进行回收呢?下面来详细分析。
1.为什么要分配在栈上?
通过JVM内存模型中,我们知道Java的对象都是分配在堆上的。当堆空间(新生代或者老年代)快满的时候,会触发GC,没有被任何其他对象引用的对象将被回收。如果堆上出现大量这样的垃圾对象,将会频繁的触发GC,影响应用的性能。其实这些对象都是临时产生的对象,如果能够减少这样的对象进入堆的概率,那么就可以成功减少触发GC的次数了。我们可以把这样的对象放在堆上,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
2.什么情况下会分配在栈上?
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象会不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存。随栈帧出栈而销毁,减轻GC的压力。
3.什么是逃逸?
那么什么是逃逸分析呢?要知道逃逸分析,先要知道什么是逃逸?我们来看一个例子
public class Test { public User test1() { User user = new User(); user.setId(1); user.setName("张三"); return user; } public void test2() { User user = new User(); user.setId(2); user.setName("李四"); } }
Test里有两个方法,test1()方法构建了user对象,并且返回了user,返回回去的对象肯定是要被外部使用的。这种情况就是user对象逃逸出了test1()方法。
而test2()方法也是构建了user对象,但是这个对象仅仅是在test2()方法的内部有效,不会在方法外部使用,这种就是user对象没有逃逸。
判断一个对象是否是逃逸对象,就看这个对象能否被外部对象访问到。
结合栈上分配来理解为何没有逃逸出去的对象为什么应该分配在栈上呢?来看下图:
Test2()方法的user对象只会在当前方法内有效,如果放在堆里,在方法结束后,其实这个对象就已经是垃圾的,但却在堆里占用堆内存空间。如果将这个对象放入栈中,随着方法入栈,逻辑处理结束,对象就变成垃圾了,再随着栈帧出栈。这样可以节约堆空间。尤其是这种非逃逸对象很多的时候。可以节省大量的堆空间,降低GC的次数。
4.什么是对象的逃逸分析?
就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为参数传递到其他地方中。 上面的例子中,很显然test1()方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
大白话说就是:判断user对象是否会逃逸到方法外,如果不会逃逸到方法外,那么就建议在堆中分配一块内存空间,用来存储临时的变量。是不是不会逃逸到方法外的对象就一定会分配到堆空间呢?不是的,需要满足一定的条件:第一个条件是JVM开启了逃逸分析。可以通过设置参数来开启/关闭逃逸分析。
-XX:+DoEscapeAnalysis 开启逃逸分析 -XX:-DoEscapeAnalysis 关闭逃逸分析
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)
5.什么是标量替换?
如果一个对象通过逃逸分析能过确定他可以在栈上分配,但是我们知道一个线程栈的空间默认也就1M,栈帧空间就更小了。而对象分配需要一块连续的空间,经过计算如果这个对象可以放在栈帧上,但是栈帧的空间不是连续的,对于一个对象来说,这样是不行的,因为对象需要一块连续的空间。那怎么办呢?这时JVM做了一个优化,即便在栈帧中没有一块连续的空间方法下这个对象,他也能够通过其他的方式,让这个对象放到栈帧里面去,这个办法就是标量替换。
什么是标量替换呢?
如果有一个对象,通过逃逸分析确定在栈上分配了,以User为例,为了能够在有限的空间里能够放下User中所有的东西,我们不会在栈上new一个完整的对象了,而是只是将对象中的成员变量放到栈帧里面去。如下图:
栈帧空间中没有一块完整的空间放User对象,为了能够放下,我们采用标量替换的方式,不是将整个User对象放到栈帧中,而是将User中的成员变量拿出来分别放在每一块空闲空间中。这种不是放一个完整的对象,而是将对象打散成一个个的成员变量放到栈帧上,当然会有一个地方标识这个属性是属于那个对象的,这就是标量替换。
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配了。开启标量替换参数是
-XX:+EliminateAllocations
JDK7之后默认开启。
6.标量替换与聚合量
那什么是标量,什么是聚合量呢?
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及 reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
7. 总结+案例分析
new出来的一部分对象是可以放在栈上的,那什么样的对象放在栈上呢?通过逃逸分析判断一个对象是否会逃逸到方法外,如果不会逃逸到方法外,那么就建议在堆中分配一块内存空间来存储这样的变量。那是不是说所有不会逃逸到方法外的对象就一定会分配到堆空间呢?不是的,需要满足一定的条件:
- 开启逃逸分析
- 开启标量替换
下面举例分析:
public class AllotOnStack { public static void main(String[] args) { long start = System.currentTimeMillis(); for (int i = 0; i < 100000000; i++) { alloc(); } long end = System.currentTimeMillis(); System.out.println(end-start); } private static void alloc() { User user = new User(); user.setId(1); user.setName("zhuge"); } }
上面有一段代码,在main方法中调用1亿次alloc()方法。在alloc()方法中,new了User对象,但是这个对象是没有逃逸出alloc()方法的。for循环运行了1亿次,这时会产生1亿个对象,如果分配在堆上,那么会有大量的GC产生;如果分配在栈上,那么几乎不会有GC产生。这里说的是几乎,也就是不一定完全没有gc产生,产生gc还可能是因为其他情况。
为了能够看到在栈上分配的明显的效果,我们分几种情况来分析:
- 默认情况下
设置参数:
我当前使用的是jdk8,默认开启逃逸分析(‐XX:+DoEscapeAnalysis),开启标量替换的(‐XX:+EliminateAllocations)。
-Xmx15m -Xms15m -XX:+PrintGC
设置上面的参数:将堆内存设置的小一些,并且设置打印GC日志,方便我们清晰的看到结果。
运行结果:
10
我们看到没有产生任何的GC。因为开启了逃逸分析,开启了标量替换。这就说明,对象没有分配在堆上,而是分配在栈上了。
有没有疑惑,为什么栈上可以放1亿对象?
因为产生一个对象,当这个方法执行完的时候,对象会随栈帧一起被回收。然后分配下一个对象,这个对象执行完再次被回收。以此类推。
- 关闭逃逸分析,开启标量替换
这种情况是关闭了逃逸分析,开启了标量替换。设置jvm参数如下:
-Xmx15m -Xms15m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations
其实只有开启了逃逸分析,标量替换才会生效。所以,这种情况是不会将对象分配在栈上的,都分配在堆上,那么会产生大量的GC。我们来看运行结果:
[GC (Allocation Failure) 4842K->746K(15872K), 0.0003706 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0003987 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0004303 secs] ...... [GC (Allocation Failure) 4842K->746K(15872K), 0.0004012 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0003712 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0003978 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0003969 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0011955 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0004206 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0004172 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0013991 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0006041 secs] [GC (Allocation Failure) 4842K->746K(15872K), 0.0003653 secs] 773
我们看到产生了大量的GC,并且耗时从原来的10毫秒延长到773毫秒
- 开启逃逸分析,关闭标量替换
这种情况是关闭了逃逸分析,开启了标量替换。设置jvm参数如下:
-Xmx15m -Xms15m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:-EliminateAllocations
其实只有开启了逃逸分析,标量替换不生效,表示的含义是如果对象在栈空间放不下了,那么会直接放到堆空间里。我们来看运行结果:
[GC (Allocation Failure) 4844K->748K(15872K), 0.0003809 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0003817 secs] ....... [GC (Allocation Failure) 4844K->748K(15872K), 0.0003751 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0004613 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0005310 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0003402 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0003661 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0004457 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0004528 secs] [GC (Allocation Failure) 4844K->748K(15872K), 0.0005270 secs] 657
我们看到开启了逃逸分析,但是没有开启标量替换也产生了大量的GC。
通常,我们都是同时开启逃逸分析和标量替换。
第二步:判断是否是大对象,不是放到Eden区
判断是否是大对象,如果是则直接放入到老年代中。如果不是,则判断是否是TLAB?如果是则在Eden去分配一小块空间给线程,把这个对象放在Eden区。如果不采用TLAB,则直接放到Eden区。
什么是TLAB呢?本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。简单说,TLAB是为了避免多线程争抢内存,在每个线程初始化的时候,就在堆空间中为线程分配一块专属的内存。自己线程的对象就往自己专属的那块内存存放就可以了。这样多个线程之间就不会去哄抢同一块内存了。jdk8默认使用的就是TLAB的方式分配内存。
通过-XX:+UseTLAB参数来设定虚拟机是否启用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
1.对象是如何在Eden区分配的呢?
这一块的详细信息参考文章:https://www.cnblogs.com/ITPower/p/15384588.html
这里放上内存分配的图,然后我们案例来证实:
案例代码:
public class GCTest { public static void main(String[] args) throws InterruptedException { byte[] allocation1, allocation2; allocation1 = new byte[60000*1024]; } }
来看这段代码,定义了一个字节数组allocation2,给他分配了一块内存空间60M。
来看看程序运行的效果,这里为了方便检测效果,设置一下jvm参数打印GC日志详情
-XX:+PrintGCDetails 打印GC相信信息
a) Eden去刚好可以放得下对象
运行结果:
Heap PSYoungGen total 76288K, used 65536K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000) eden space 65536K, 100% used [0x000000076ab00000,0x000000076eb00000,0x000000076eb00000) from space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000) to space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000) ParOldGen total 175104K, used 0K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000) object space 175104K, 0% used [0x00000006c0000000,0x00000006c0000000,0x00000006cab00000) Metaspace used 3322K, capacity 4496K, committed 4864K, reserved 1056768K class space used 365K, capacity 388K, committed 512K, reserved 1048576K
- 新生代约76M
- Eden区约65M,占用了100%
- from/to月1M,占用0%
- 老年代月175M,占用0%
- 元数据空间约3M,占用365k。
我们看到新生代Eden区被放满了。其实我们的对象只有60M,Eden区有65M,为什么会被放满呢?因为Eden区还存放了JVM启动的一些类。因为Eden区能够放得下,所以不会放到老年代里。
元数据空间约3M是存放的方法区中类代码信息的镜像。我们在上面类型指针里面说过方法区中元数据信息在堆中的镜像。
对于Math类来说,他还有一个类对象, 如下代码所示:
Class<? extends Math> mathClass = math.getClass();这个类对象是存储在哪里的呢?这个类对象是方法区中的元数据对象么?不是的。这个类对象实际上是jvm虚拟机在堆中创建的一块和方法区中源代码相似的信息。如下图堆空间右上角。
b) Eden区满了,会触发GC
public class GCTest { public static void main(String[] args) throws InterruptedException { byte[] allocation1, allocation2; /*, allocation3, allocation4, allocation5, allocation6*/ allocation1 = new byte[60000*1024]; allocation2 = new byte[8000*1024]; } }
来看这个案例,刚刚设置allocation1=60M Eden区刚好满了,这时候在为对象allocation2分配8M,因为Eden满了,这是会触发GC,60M from/to都放不下,会直接放到old老年代,然后将allocation2的8M放到Eden区。来看运行结果:
[GC (Allocation Failure) [PSYoungGen: 65245K->688K(76288K)] 65245K->60696K(251392K), 0.0505367 secs] [Times: user=0.25 sys=0.04, real=0.05 secs] Heap PSYoungGen total 76288K, used 9343K [0x000000076ab00000, 0x0000000774000000, 0x00000007c0000000) eden space 65536K, 13% used [0x000000076ab00000,0x000000076b373ef8,0x000000076eb00000) from space 10752K, 6% used [0x000000076eb00000,0x000000076ebac010,0x000000076f580000) to space 10752K, 0% used [0x0000000773580000,0x0000000773580000,0x0000000774000000) ParOldGen total 175104K, used 60008K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000) object space 175104K, 34% used [0x00000006c0000000,0x00000006c3a9a010,0x00000006cab00000) Metaspace used 3323K, capacity 4496K, committed 4864K, reserved 1056768K class space used 365K, capacity 388K, committed 512K, reserved 1048576K
和我们预测的一样
- 年轻代76M,已用9343k
- Eden65M,占用了13%,这13%就有allocation2分配的80M,另外的部分是jvm运行产生的
- from区10M,占用6%。这里面存的肯定不是allocation1的60M,因为存不下,这里的应该是和jvm有关的数据
- to去10M,占用0%
- 老年代175M,占用了60M,这60M就是allocation1回收过来的
- 元数据占用3M,使用365k。这一块数据没有发生变化,因为元数据信息没有变。
2.对象动态年龄判断
当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,
例如:Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。
对象动态年龄判断机制一般是在minor gc之后触发的。
3.老年代空间分配担保机制
- 年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间。如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了,如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。
- 如果上一步结果是小于或者之前说的参数没有设置,那么就会直接触发一次Full GC,然后在触发Minor GC, 如果回收完还是没有足够空间存放新的对象就会发生"OOM"
- 如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full GC,Full GC完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”。
在梳理一下这块逻辑,为什么叫担保机制。在触发Minor GC的时候,进行了一个条件判断,预估老年代空间是否能够放的下新生代的对象,如果能够放得下,那么就直接触发Minor GC, 如果放不下,那么先触发Full GC。在触发Full GC的时候设置了担保参数会增加异步判断,而不是直接触发Full GC。判断老年代剩余可用空间 是否小于 历史每次Minor GC后进入老年代对象的平均值。这样的判断可以减少Full GC的次数。因为新生代在触发Full GC以后是会回收一部分内存的,剩余部分再放入老年代,可能就能放下了。
第三步 是大对象 放入到老年代
1.什么是大对象?
- Eden园区放不下了肯定是大对象。
- 通过参数设置什么是大对象。-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC。如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。
- 长期存活的对象将进入老年代。虚拟机采用分代收集的思想来管理内存,虚拟机给每个对象设置了一个对象年龄(Age)计数器。 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
2.为什么要将大对象直接放入到老年代呢?
为了避免为大对象分配内存时的复制操作而降低效率。
3.什么情况要手动设置分代年龄呢?
如果我的系统里80%的对象都是有用的对象,那么经过15次GC后会在Survivor中来回翻转,这时候不如就将分代年龄设置为5或者8,这样减少在Survivor中来回翻转的次数,直接放入到老年代,节省了年轻代的空间。
4.如何判断一个类是无用的类?
方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?类需要同时满足下面三个条件,才是“无用的类”。
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class培优在任何地方被引用,无法在任何地方通过放射访问该类的方法。
这里重点说一下“无用的类”第二个条件,加载该类的ClassLoader已经被回收。来看看Classloader的结果图:
其中,前三个类加载器(引导类加载器,扩展类加载器,应用程序类加载器)是系统类加载器,他们加载了很多的类,这些类加载器被回收,几乎是不可能的。但是对于自定义类加载器,是可以被回收的。以tomcat自定义类加载器为例,一个jsp就会自动生成一个类加载器,当这个类加载器处理完当前jsp页面,就会被回收了。也就是,通常只有自定义类加载器才会被回收。
这篇关于牛批!终于有人把JVM内存分配机制讲明白了!超详细解析!的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-29RocketMQ底层原理资料详解:新手入门教程
- 2024-11-29RocketMQ源码资料解析与入门教程
- 2024-11-29[开源]6.1K star!这款电视直播源神器真的太赞啦!
- 2024-11-29HTTP压缩入门教程:轻松提升网页加载速度
- 2024-11-29JWT开发入门指南
- 2024-11-28知识管理革命:文档软件的新玩法了解一下!
- 2024-11-28低代码应用课程:新手入门全攻略
- 2024-11-28哪些办公软件适合团队协作,且能够清晰记录每个阶段的工作进展?
- 2024-11-28全栈低代码开发课程:零基础入门到初级实战
- 2024-11-28拖动排序课程:轻松掌握课程拖动排序功能