JVM系列--对象内存分配技术分析

简介: JVM系列--对象内存分配技术分析

上一篇文章中我们有说到过关于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引用。

目录
相关文章
|
2月前
|
存储 算法 Java
散列表的数据结构以及对象在JVM堆中的存储过程
本文介绍了散列表的基本概念及其在JVM中的应用,详细讲解了散列表的结构、对象存储过程、Hashtable的扩容机制及与HashMap的区别。通过实例和图解,帮助读者理解散列表的工作原理和优化策略。
43 1
散列表的数据结构以及对象在JVM堆中的存储过程
|
2月前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
356 1
|
8天前
|
存储 Java 程序员
【JVM】——JVM运行机制、类加载机制、内存划分
JVM运行机制,堆栈,程序计数器,元数据区,JVM加载机制,双亲委派模型
|
29天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
11天前
|
缓存 Java
JVM对象引用
本次课程聚焦JVM对象引用,涵盖强引用、软引用、弱引用和虚引用。强引用是最常见的引用类型,确保对象不会被垃圾回收器回收,适用于需要确保对象存活的场景;软引用在内存不足时会被优先回收,常用于缓存;弱引用的对象随时可能被回收,适合临时对象;虚引用最弱,主要用于接收对象回收通知,进行资源清理。通过合理选择引用类型,可优化内存管理,避免内存泄露。
|
2月前
|
并行计算 算法 测试技术
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面,旨在通过综合策略提升程序性能,满足实际需求。
65 1
|
2月前
|
JavaScript
如何使用内存快照分析工具来分析Node.js应用的内存问题?
需要注意的是,不同的内存快照分析工具可能具有不同的功能和操作方式,在使用时需要根据具体工具的说明和特点进行灵活运用。
51 3
|
2月前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80
|
2月前
|
Java
JVM运行时数据区(内存结构)
1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
25 3
|
2月前
|
存储 缓存 监控
Elasticsearch集群JVM调优堆外内存
Elasticsearch集群JVM调优堆外内存
54 1

热门文章

最新文章