一、对象的创建
之前一篇详细分析过类的加载,本篇主要记录分析对象的创建步骤以及jvm内存的分配。
直接上图
显而易见,对象的创建大致经过7个步骤:
1.类是否加载判断,如果想要创建一个类的实例对象,首先这个类是要被加载完成之后才可以,所以第一步就要判断类是否被加载过,若未被加载,则先加载类。
2.内存分配,创建对象之前首先需要在堆中分配一块足够大的内存空间,具体多大,这个在对应的类加载完成之后,jvm就已经可以确定这类的对象需要占用多大的内存了,所以只要在堆内存中划分一块确定的内存空间即可。内存分配的方式有两种:
- 指针碰撞法:堆内存中的空间比较规整,所有已经使用的内存空间规整的排列在一起,然后使用一个指针把已使用的内存空间和未使用的内存空间分隔开来,当一个对象需要分配内存时,只需要把指针向后移动对象需要的大小即可,jvm默认使用这种方法。
- 空闲列表法:对象在堆内存中的分配比较随意凌乱,所以jvm在内部维护了一个可用内存列表,当一个对象需要分配内存时,在空闲列表找出一块足够大的内存给新生对象,然后这块内存从空闲列表中移除。
内存分配的并发问题:
不管使用哪种分配方式都有并发问题,例如:使用指针碰撞法分配内存空间,A对象和B对象同时获取到指针,给A分配内存后,B又使用老的指针进行分配。
解决方案:
- 使用CAS+重试的方式分配内存,在内存分配时,使用CAS原子操作进行内存的分配,如果CAS操作失败的就进行重试 一直到成功为止,这种方式性能较低。
- 本地线程缓冲(Thread Local Allocation Buffer,TLAB),这堆中种方式就是预先在给线程分配一块空间,之后每个线程需要创建对象,就在属于自己的堆内存空间中分配,这样就避免了并发分配问题,这种是JVM默认的解决并发分配的方式,可以使用配置参数:-XX:+/-UseTLAB配置是否开启此方法(默认开启),-XX:TLABSize配置TLAB的大小。
3.初始化
在堆中给新生对象分配好内存空间之后,虚拟机把分配到的这块内存空间都初始化为零(不包括对象头),如果使用了TLAB,这一过程也可以提前至TLAB分配时进行,这样可以保证对象的实例在java代码中不赋初始值就可以使用,程序可以访问到这些字段的零值。
4.设置对象头
java对象包括三个部分,对象头,实例数据区,对齐填充位,其中对象头中保存了对象的hashCode,分代年龄,锁的标志位信息,这一块被称为mark word,除此之外还有类元信息指针,针对数组对象还记录了数组长度等信息,所以一个对象被创建成功前是需要设置对象头的信息的,换句话说,就是把对象的hashCode、分代年龄,类元信息指针都赋好值。
针对对象头这一块做一个特别说明:
对象头也有三块组成:
- mark word:在32位操作系统中,占4个字节 ,在64位操作系统下占8个字节,如下
锁状态
25bit
4bit
1bit
2bit
23bit
2bit
是否偏向锁
锁标志位
无锁
对象的HashCode
分代年龄
0
01
偏向锁
线程ID
Epoch
分代年龄
1
01
轻量级锁
指向栈中锁记录的指针
00
重量级锁
指向重量级锁的指针
10
GC标记
空
11
- klass point 指针 指向方法区类的元数据指针,这个指针在32位操作系统中占4个字节,在64位操作系统占8个字节,开启指针压缩,会压缩到4个字节,jvm默认是开启指针压缩的,可以节省内存空间。
jvm配置参数:-XX:+UseCompressedOops(默认开启)开启指针压缩,-XX:-UseCompressedOops关闭指针压缩
为什么要指针压缩?
- 在64位平台下Hotspot使用32位指针(4字节),可用内存大概多出1.5倍左右,如果使用较大内存指针在主内存和缓存中移动,占用较大带宽,对象GC的压力也比较大。
- 为了减少64位平台下的内存消耗,启用指针压缩
- 在jvm中,32位地址大概支持4G(2的32次方)内存大小,可以通过对对象指针的压缩编码,然后通过解码的方式进行优化,这样只需要使用32位地址就可以了。
- 堆内存大于32G时,压缩指针会失效,jvm会强制使用64位来对java对象寻址,这样就会出现1的问题,所以堆的内存尽量不要大于32位为好。
- 数组长度,这一块只有在数组对象重才存在,其他对象是没有的。
以上就是java对象创建的步骤。
二、jvm内存模型
jvm内存划分主要分为堆内存,方法区,栈内存,本地方法栈,程序计数器。
堆内存:主要存放创建的对象,几乎所有被创建出来的对象都要在对象分配内存空间,是所有线程共享的一块内存区域。
方法区:主要存放静态变量,静态方法,常量,类元数据等信息,所有线程共享。
线程栈:线程被创建出来之后,会分配一块内存空间,这块空间只是线程私有,不能共享,这就是线程栈,线程每调用一个方法,就会在线程栈中开辟出一块栈帧区域,随着栈帧的进栈出栈标志着方法运行的开始和结束。每个栈帧都有自己的局部变量表,操作数栈,动态链接,方法返回地址等。
本地方法栈:存放java中native本地方法的信息 属于线程私有
程序计数器:记录的是程序执行的索引位置,也是因这,当发生线程切换之后,可以从某一个位置继续执行。
线程栈的分配:
'
jvm内存配置项:
-Xms:堆内存大小 -Xms:100m配置堆内存为100M
-Xmx:堆最大内存 -Xmx:500m配置堆最大内存为500M
-Xmn:配置新生代内存大小
-XX:MetaSpaceSize方法区元空间大小
-XX:MaxMetaSpaceSize 方法区元空间最大
-Xss:线程栈内存大小
关于JVM方面的优化,也就是根据业务的需求尽可能的通过配置这些参数来减少GC的次数,一般主要减少Full GC的次数,使Full GC几乎不发生。
三、对象内存分配
我们都知道,java新生对象一般都会在堆中分配内存,其实除了堆之外,还有可能会在栈中分配内存,
- 逃逸分析:即jvm判断对象会不会逃离某个方法,比如在某个方法中创建对象,方法执行完成后 对象就成了垃圾对象,这就是对象没有逃离方法之外,针对这种情况,jvm可以做很多优化,例如,锁消除,栈上分配等。
如果判断对象并不会被外部访问,也就是未逃逸出方法,jvm会进行栈上分配,但是jvm一般不会直接在栈内创建对象,而是将对象的成员变量分解成若干个可以被方法使用的局部变量所代替,这些代替的局部变量在栈帧或寄存器中分配空间,这样就不会因为没有一大块连续的内存空间而导致无法在栈上分配,这种方式有个专业名词:标量替换。
public class AllotOnStack {
/**
* 代码调用了1亿此的alloc方法 假设在对上分配内存
* 调用1亿次 产生1亿个Student对象 假设一个对象8B大小 总大小为:(8*1亿)/1000/1024 = 781MB 大概1GB 如果堆内存小于此值 则必然触发GC
*
* jvm堆内存配置 -Xms15m -Xmx15m
*
* 如下配置不会发生大量的GC 几乎不会发生GC 开启逃逸分析 开启标量替换
* -XX:+DoEscapeAnalysis(开启逃逸分析) -XX:+PrintGC -XX:+EliminateAllocations(开启标量替换)
*
* 如下配置会发生大量GC 不开启逃逸分析 开启标量替换(开启也没用 因为没有开启逃逸分析 标量替换是在开启逃逸分析情况下生效)
* -XX:-DoEscapeAnalysis(不开启逃逸分析) -XX:+PrintGC -XX:+EliminateAllocations(开启标量替换)
*
* 如下配置会发声大量GC 开启逃逸分析 不开启标量替换
* -XX:+DoEscapeAnalysis(开启逃逸分析) -XX:+PrintGC -XX:-EliminateAllocations(开启标量替换)
*
* @param args
*/
public static void main(String[] args) {
long l = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long l1 = System.currentTimeMillis();
System.out.println(l1-l);
/**
* 结论:栈上分配内存 依赖逃逸分析和标量替换
*/
}
public static void alloc() {
Student student = new Student();
student.setName("zhangsan");
student.setAge(20);
}
}
jvm配置参数:-XX:+/-DoEscapeAnalysis(开启或者关闭逃逸分析,默认开启),-XX:+/-EliminateAllocations(开启/关闭标量替换,默认开启)
标量替换依赖逃逸分析,如果开启了标量替换,但是没有开启逃逸分析,是不生效的。下面我们来验证下各种情况发生下GC的情况:创建1亿个对象,假设每个对象8字节大小,总共需要近1G内存,设置堆内存为15MB 并且打印GC信息。
第一种:开启逃逸分析 -XX:+DoEscapeAnalysis 开启标量替换 -XX:+EliminateAllocations
几乎不发生GC。
第二种:开启逃逸分析 -XX:+DoEscapeAnalysis,不开启标量替换 -XX:-EliminateAllocations
发生大量GC。
第三种:关闭逃逸分析 -XX:-DoEscapeAnalysis,开启标量替换 -XX:+EliminateAllocations
发生大量GC。
- 大对象:大对象是可以使用jvm参数(PretenureSizeThreshold)配置的,对象达到配置的大小,会直接进入老年代,但是这个参数只有在使用serialGC或ParNew两个垃圾收集器时才有用。
public class BigObjectTest {
/**
* -XX:PretenureSizeThreshold=1000000(单位字节) 配置指定大对象的大小 1000kb 约为1MB大小
* 次参数 只有在使用Serial和ParNew两个垃圾收集器时才有用
* 所以还需要配置
* -XX:+UseSerialGC
*
* 配置 -XX:+PrintGCDetails查看GC详细信息
* @param args
*/
public static void main(String[] args) {
//一个5MB大小的数组对象 > 1MB 应该直接进入老年代
byte[] bytes = new byte[5000 * 1024];
}
}
配置大对象的大小约为1M大小,配置使用Serial垃圾收集器,然后分配一个大小为5M的数组对象查看结果:
可以看到分配给数组的5M直接分配在了老年代,至于年轻代伊甸园区使用的10%是代码运行中其他对象占用的。
- 在eden区分配对象,年轻代中分为eden区,from区,to区 3块区域,比例默认8:1:1(可以通过jvm参数动态修改这个比例)。一般新生对象都是分配在eden区,当eden区满了之后,需要发生一次Minor GC,会把剩余的对象移动到from区(from区装不下,直接移到老年代)。当第二次eden区满了之后,会把eden和from区的剩余对象移到to区,之后再GC会把eden和to区的对象移动到from区,这样一直来回的在from和to之间移动。当一个对象在年轻代经过了15次(可以配置 但是只能 <=15)GC,就需要移到老年代了。
Minor GC/Young GC:指发生在年轻代的垃圾回收动作,效率高速度快,但是只清除年轻代的垃圾对象。
Major GC/Full GC:指发生一次全面的垃圾回收动作,作用范围包括老年代,年轻代,方法区等内存区域,发生Full GC 会有一次比较长的STW(Stop The World),效率低,速度慢,清除的垃圾对象较多。
public class GCTest {
//配置 -XX:+PrintGCDetails 打印Gc的详细信息
public static void main(String[] args) {
//创建一个bute的数组 大概占用70MB
byte [] allocate1 = new byte[70000*1024];
/**
* 配置 -Xmn100m 年轻代大小为100MB 则eden占80MB from区占10MB to区占10MB
*/
byte[] allocate2 = new byte[10000 * 1024]; //占用大约10MB 此时eden区的内存大小不够了 需要发生一次GC 然后移动到 from区 from区不够用 移动到老年代
/**
* 后面的对象还是会分配到eden的区
*/
// byte[] allocate3 = new byte[1000 * 1024];
// byte[] allocate4 = new byte[1000 * 1024];
// byte[] allocate5 = new byte[1000 * 1024];
// byte[] allocate6 = new byte[1000 * 1024];
}
}
eden:80M from:10M to:10M
结果:
发生了一次GC
[GC (Allocation Failure) [PSYoungGen: 76155K->744K(89600K)] 76155K->70744K(249344K), 0.0343667 secs] [Times: user=0.08 sys=0.00, real=0.03 secs]
Heap
PSYoungGen total 89600K, used 11512K //年轻代占用的10MB是分配给allocate2引用的对象 在此之前 发生了一次GC 数据移动到了老年代
eden space 76800K, 14% used [0x00000007b9c00000,0x00000007ba684188,0x00000007be700000)
from space 12800K, 5% used [0x00000007be700000,0x00000007be7ba020,0x00000007bf380000)
to space 12800K, 0% used [0x00000007bf380000,0x00000007bf380000,0x00000007c0000000)
ParOldGen total 159744K, used 70000K//allocate1引用的占用70MB被移动到了old区 allocate2引用的10MB被分配到了年轻代
object space 159744K, 43% used [0x00000006c1c00000,0x00000006c605c010,0x00000006cb800000)
Metaspace used 3488K, capacity 4498K, committed 4864K, reserved 1056768K
class space used 387K, capacity 390K, committed 512K, reserved 1048576K
分析:
首先70M的allocate1对象分配到了eden区,之后10M的allocate2对象也需要在eden区分配,但是发现eden区不够用了,所以发生了一次Minor GC 把70M的allocate1移到from区 但是from区不够用,所以直接移动到老年代。然后在eden区给10M的allocate2分配内存空间,所以就出现了 年轻代占用约10M 老年代占用约70M。
- 长期存活的对象进入老年代,jvm通过分代年龄思想来管理堆内存中的对象的,当对象在eden区出生以后,发生一次Minor Gc并且from/to区能够存下,那么对象的年龄就会+1,并且被移入from/to区中,没发生一次Minor Gc 对象年龄都会+1,并且在from和to区之间来回移动,当对象年龄达到一定程度(一般是15岁,CMS是6岁,可以通过JVM参数进行设置,但是都不会超过15岁)后,会被移入老年代。
年轻代一般都是存放一些朝生夕死的对象,每次发生在年轻代的Minor GC效率都是很高的速度比Full GC快很多,当一个对象变成了“老当益壮”,jvm就会认为你还能活挺久的,就不让在待在年轻代玩了,会把你移入老年代,所以老年代一般都是存放一些存活时间较长的对象,并不容易被GC回收。
反过来说,jvm判断你可能还会存活很长时间,如果不把你移到老年代,还在年轻代的话,年轻代的GC计算时每次都要把你计算进去,就是明知道你不会被回收,也要操你的心,这其实是比较影响到Minor GC的效率的,而Minor GC有时相比Full GC频繁的多的,所以移动到老年代,在下次Minor GC时,就可以不用考虑这些对象了,效率会有所提升。
- 老年代空间分配担保机制
结论:通过以上分析,可以详细了解到jvm内存模型以及对象分配内存分配的工作原理,通过一些JVM参数可以进行项目调优,使得项目在运行过程中几乎不发生Full GC,因为一旦发生GC是要触发STW机制的,这在紧急时刻是很影响性能的,所以掌握对JVM的调优技能是很重要的。
为什么要STW?
例如在进行GC时,线程从Root GC开始,找到引用链,被标记的都是非垃圾对象。
如果不STW会怎样?我标记好了,正准备回收垃圾对象呢,突然又生成了一个新的对象,而这个对象是未被标记的,那么就会被垃圾回收器回收掉,这显然有问题的。
所以在GC时必然要进行STW。