上一篇文章中我们有说到过关于tlab技术相关的内容点,这期我们就来深入一起了解关于对象内存分配背后的技术原理。
什么是TLAB
在上一篇文章中我们有提及到对象在JVM中的内存管理,大部分情况下对象的分布都是存储在Java堆中存储的,但是如果从JVM设计的角度来思考,直接分配在一个处于高度竞争环境下的公共内存区域是否合理呢?
如下方代码所示:
public class AllocObj { public User alloc(int id, String name) { User user = new User(); user.setId(id); user.setUsername(name); return user; } public static void main(String[] args) { AllocObj allocObj = new AllocObj(); for (int i = 0; i < 100000000; i++) { User user = allocObj.alloc(i, "idea-" + i); } } } 复制代码
这一段程序中,alloc函数中使用了User对象,如果我们作为一名JVM的设计者,是否需要直接将User对象存储在堆这个公共的且处于多线程高度竞争内存空间模块中,还是说需要再做一些优化手段呢?
按照这样的设想思路,Jvm可以将一些对象的创建通过独立的空间进行保护,从而尽量避免指针碰撞带来的性能影响。
指针碰撞带来的影响
Jvm中将堆设计为了内存区域最大的一个模块,主要是用于存储一些公用的对象,在进行对象分配的时候会有可能发生较为激烈的“指针碰撞”情况,为了尽可能减少这种情况的发生,Jvm为一些专属于线程独有的对象开辟了一个属于线程私有的内存空间,简称为Tlab(ThreadLocalAllocBuffer)。
TLAB的内存分配过程
Jvm(Hotspot JDK 1.8)堆中的年轻代的Eden区会开辟一块特殊的内存区域,然后该内存区域会分配一定的空间给需要的线程专门使用。如下图所示:
不同的线程在eden区中有一块专属的内存区域用于存储分配对象,这些个内存区域在不同线程之间分配权是各自独立的,但是访问权限是互通的。默认情况下Tlab区域只占用eden区域的1%,也是会被垃圾收集器进行回收处理的。
refill_waste参数
在分配对象的过程中,假设tlab只有100kb的大小,已经分配了80kb的内存空间,那么此时又需要分配30kb空间的对象会如何处理呢?这个时候就需要使用到refill_waste(最大浪费空间)参数了。
假设refil_waste设置为25kb,那么此时则会重新将旧有内存区域的数据拷贝到新的,更大的tlab区域中(100+25kb),此时就可以分配所需的30kb对象了,并且剩余空间为 125-80-30 = 15 kb空间。如果再进行一次额外的30kb大小对象分配,此时剩余的15kb不足,就直接走tlab空间之外的堆内存空间分配。
Tlab只是HotSpot虚拟机的一个优化方案,Java虚拟机规范中也没有关于Tlab的任何规定。所以,不代表所有的虚拟机都有这个特性。
实战–TLAB参数设置的实际效果如何?
来看以下代码:
为了尽量真实模拟,假设说在对线程竞争比较激烈的情况下,有一个计算任务需要循环执行十万次:
public class TLABTest { public static void alloc() { byte[] b = new byte[2]; b[0] = 1; for(int i =0; i<10;i++){ User u1 = new User(); String t = new String(); t = "idea"; u1.setUsername("idea"); u1.setId(1001); } } public static void main(String[] args) { long begin = System.currentTimeMillis(); CountDownLatch countDownLatch = new CountDownLatch(1); CountDownLatch totalFinish = new CountDownLatch(10); for (int i=0;i<10;i++){ Thread t = new Thread(new Runnable(){ @Override public void run() { try { System.out.println("堵塞ing"); countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } for (int i = 0; i < 100000; i++) { alloc(); } totalFinish.countDown(); } }); t.start(); } countDownLatch.countDown(); try { totalFinish.await(); } catch (InterruptedException e) { e.printStackTrace(); } long end = System.currentTimeMillis(); System.out.println("耗时:" + (end - begin)); } } 复制代码
测试参数(为了避免栈上分配的影响,需要将逃逸分析关闭掉),这段是不同的启动参数对比:
-XX:+UseTLAB -Xcomp -XX:-BackgroundCompilation -XX:-DoEscapeAnalysis -server -XX:-UseTLAB -Xcomp -XX:-BackgroundCompilation -XX:-DoEscapeAnalysis -server 复制代码
实验机器:
mac pro笔记本,内存16gb,jdk1.8环境下,jvm的堆栈大小均为默认配置。
实验数据:
开启tlab特性,耗时:233ms
关闭tlab特性,耗时:3405ms
不同的参数得出的分配时间有很明显的变化,开启了tlab之后时间明显消耗下降了大约10倍左右。
上边关于对象分配中稍微提到了一个叫做栈上分配的概念,关于栈上分配的更多详细细节可以翻看之前我写过的一篇文章:(jvm的逃逸分析)
在Hotspot实现的虚拟机中,通常默认都是优先在栈上尝试进行内存分配,如果栈上分配失败了,才会尝试使用Tlab,所以整体的对象分配过程中,对象分配步骤可以理解为下图:
读到这里你可能会有疑惑,既然都有了栈上分配技术能够减轻对于堆中分配对象时候的指针碰撞影响,为什么又多出来一个TLAB的东西呢?
我个人的理解是:Tlab技术的目的是为了尽可能让线程在堆中进行对象分配的时候不需要那么快就使用到公共分配空间的内存区域,尽可能先在自己专属的tlab区域中进行分配。而且Tlab技术管理的对象也是需要参与到gc回收的,而栈上分配的对象在函数执行结束,出栈的时候就结束自己的生命空间了,对于gc的压力为0。
对象在内存中的存储结构
上边我们系统学习了关于Hotspot Jvm中的对象分配基本流程,下边我都是基于采用 HotSpot 虚拟机来进行对象内存布局的深入讲解。
当我们使用 Java 程序 new 了一个对象之后,该对象通常都会存在于堆内存中(内存逃逸情况除外),此时对象(对于一些数据等特殊类型的对象结构另说)的一个存储结构我用下边的一张图来进行演示:
对象头部存储了什么
在常规对象的分布图里,Header 存储着重要的数据信息,下边是一张关于 Header 存储数据信息的布局图:
可以看到 Header 中存储着各种和对象本身有关联的运行时数据,例如:hashcode,对象的分代年龄,而且似乎随着锁状态的变化,内部存储的信息也在发生变动,通常业界也会把这部分数据称之为 Mark Word。
下边我们来根据不同的锁状态对这部分底层的细节逐一讲透。(下文中所说的加锁默认是指在 synchorized 监控的条件下)
无锁状态
首先来说说无锁。当一个对象没有被多个线程锁访问的时候,它是不存在数据竞争问题的,此时处于“无锁”状态。
那么这个时候该对象内部的 Mark Word 布局会如下所示:
这里面我解释一下分代年龄。
分代年龄就是垃圾回收中,年轻代到老年代过程中,需要先从 eden 区到 survivor 区,过程中会有一个值专门记录经历了垃圾回收的次数,每个对象在经历了一次垃圾回收之后,对应的分代年龄就会 +1,而该数值在“无锁”状态下就是存放在了对象头这里。
PS:bit 作为计算机的最基本计算单位,用于存放二进制数字,4bit 即存放 4 位二进制数,也就是说对象的分代年龄上限至多为 15。注意并不是所有的垃圾回收器规则中都说明对象要连续回收存活 15 次才能到达老年代,CMS 垃圾回收器就有些特殊。
偏向锁状态
当出现了多个线程同时访问同一被加锁的对象的时候,会先进入到偏向锁阶段。
其实偏向锁的设计本意是为了减少锁在多线程竞争下对机器性能的消耗。具体的方式是:当一个线程访问到 monitor 对象的时候,会在 Mark Word 里记录请求的线程 id,并且将偏向锁 id 进行标记。
这样的好处在于下一次有请求线程访问的时候,只需要读取该 monitor 的线程 id 是否和自身 id 一致即可。这里需要注意一点,偏向锁在进行加锁的时候是通过 CAS 操作来修改 Mark Word 的,但是一旦出现了多个线程访问同一个 monitor 对象的时候,偏向锁就会进入撤销状态。
进入撤销状态之前,会做一个全局性的检测,判断当前时间点里是否有其他的字节码在执行,如果没有则会进入撤销状态。撤销过程的相关细节点如下所示:
如果一旦出现了多个线程竞争偏向锁,那么此时偏向锁就会进行撤销然后进入一个锁升级的步骤,进入到了轻量级锁的环节中。
轻量级锁状态
进入了轻量级锁状态之后,原先对象头内部的那些线程 id、epoch、分代年龄,是否为偏向锁系列的信息会被挪到一个叫做 Lock Record 的位置存储在抢夺该 monitor 的自身线程上边。
当多个竞争的线程进行抢夺该 monitor 的时候会采用 CAS 的方式进行抢夺,当抢夺次数超过 10 次,或者当前 CPU 资源占用大于 50% 的时候,该锁就会从轻量级锁的状态上升为了重量级锁。
重量级锁状态
在之前所说的轻量级锁中,都是基于 JVM 层面的,相比于介入内核态的操作来说是属于轻量化的操作。但是这里我们需要先弄清楚一点:
并非说一直采用 CAS 的轻量级锁就一定会比重量级锁的性能要好,假设有十万个线程都在执行 CAS 操作,那么此时对于 CPU 的耗费就会是非常巨大的,这种场景下可以通过借助 OS 内核态的排队机制来做优化,因此轻量级锁在某种程度上晋升为重量级锁也是一种优化的手段。而重量级锁的状态下,对象头部的基本结构如下所示:
对象的实例数据
关于理解对象的实例数据这部分信息还是比较简单的,例如下边的一个 user 对象:
public class User { private Integer userId; private String username; } 复制代码
这里面的 userId、username 就是这个对象的实例信息,这部分的数据通常占据了对象在内存中存储的主要空间。
对象的对齐填充
在 JVM 里有一条默认的规矩,就是每个对象的占用大小都得是 8bit 的整数倍,因此对齐填充部分主要是起到一个补充不足 8bit 整数倍空间的作用。
对象的内存存储模块最终分布图基本如下所示:
线程是如何访问堆里面的对象呢?
关于这个问题,相信大部分人都大概能够联想到是通过一个类似指针一样的东西去进行访问对应的内存地址,然后读取对象,那么是否说Hotspot早期就采用这种方式去设计实现呢?是否还有其他的方式来实现对象访问呢?
基于句柄池的设计实现
这张图中采用了基于对象句柄的方式来进行堆中对象的访问,首先java线程的本地变量表中会存储一个refernce引用指针,这个指针是指向到堆中的句柄池中,注意此时还没有指向到实际的堆对象,需要再次根据句柄的地址进行寻址访问才能找到最终的对象。(一共是两次访问操作)
优点:当堆空间的内存进行回收的时候,对象的地址会发生变动,此时只需要对句柄池做调整就好,不需要关心到栈。
缺点:需要进行二次转址访问的处理,效率较低。
基于直接指针访问的设计
直接通过指针方式访问(Hotspot是主要使用这种方式进行对象访问的),这种方式的好处在于访问对象时候的次数减少,也是Hotspot虚拟机中对象访问时候所采用的方式。
优点:访问次数少
缺点:当对象的内存布局发生变化的时候,需要和每个关联的线程栈“打交道“,更新其中的reference引用。