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引用。

目录
相关文章
|
15天前
|
Web App开发 监控 JavaScript
监控和分析 JavaScript 内存使用情况
【10月更文挑战第30天】通过使用上述的浏览器开发者工具、性能分析工具和内存泄漏检测工具,可以有效地监控和分析JavaScript内存使用情况,及时发现和解决内存泄漏、过度内存消耗等问题,从而提高JavaScript应用程序的性能和稳定性。在实际开发中,可以根据具体的需求和场景选择合适的工具和方法来进行内存监控和分析。
|
14天前
|
存储 算法 Java
散列表的数据结构以及对象在JVM堆中的存储过程
本文介绍了散列表的基本概念及其在JVM中的应用,详细讲解了散列表的结构、对象存储过程、Hashtable的扩容机制及与HashMap的区别。通过实例和图解,帮助读者理解散列表的工作原理和优化策略。
29 1
散列表的数据结构以及对象在JVM堆中的存储过程
|
9天前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
10天前
|
开发框架 监控 .NET
【Azure App Service】部署在App Service上的.NET应用内存消耗不能超过2GB的情况分析
x64 dotnet runtime is not installed on the app service by default. Since we had the app service running in x64, it was proxying the request to a 32 bit dotnet process which was throwing an OutOfMemoryException with requests >100MB. It worked on the IaaS servers because we had the x64 runtime install
|
7天前
|
Java Linux Windows
JVM内存
首先JVM内存限制于实际的最大物理内存,假设物理内存无限大的话,JVM内存的最大值跟操作系统有很大的关系。简单的说就32位处理器虽然可控内存空间有4GB,但是具体的操作系统会给一个限制,这个限制一般是2GB-3GB(一般来说Windows系统下为1.5G-2G,Linux系统下为2G-3G),而64bit以上的处理器就不会有限制。
8 1
|
11天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
34 4
|
1月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
65 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
20天前
|
Web App开发 JavaScript 前端开发
使用 Chrome 浏览器的内存分析工具来检测 JavaScript 中的内存泄漏
【10月更文挑战第25天】利用 Chrome 浏览器的内存分析工具,可以较为准确地检测 JavaScript 中的内存泄漏问题,并帮助我们找出潜在的泄漏点,以便采取相应的解决措施。
134 9
|
26天前
|
存储 算法 Java
聊聊jvm的内存结构, 以及各种结构的作用
【10月更文挑战第27天】JVM(Java虚拟机)的内存结构主要包括程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和运行时常量池。各部分协同工作,为Java程序提供高效稳定的内存管理和运行环境,确保程序的正常执行、数据存储和资源利用。
46 10
|
24天前
|
并行计算 算法 IDE
【灵码助力Cuda算法分析】分析共享内存的矩阵乘法优化
本文介绍了如何利用通义灵码在Visual Studio 2022中对基于CUDA的共享内存矩阵乘法优化代码进行深入分析。文章从整体程序结构入手,逐步深入到线程调度、矩阵分块、循环展开等关键细节,最后通过带入具体值的方式进一步解析复杂循环逻辑,展示了通义灵码在辅助理解和优化CUDA编程中的强大功能。