求你了,别再说Java对象都是在堆内存上分配空间的了!

https://baijiahao.baidu.com/s?id=1661296872935371634&wfr=spider&for=pc

计算机java编程

发布时间: 20-03-1613:22科技达人,优质创作者

Java作为一种面向对象的,跨平台语言,其对象、内存等一直是比较难的知识点,所以,即使是一个Java的初学者,也一定或多或少的对JVM有一些了解。可以说,关于JVM的相关知识,基本是每个Java开发者必学的知识点,也是面试的时候必考的知识点。

在JVM的内存结构中,比较常见的两个区域就是堆内存和栈内存(如无特指,本文提到的栈均指的是虚拟机栈),关于堆和栈的区别,很多开发者也是如数家珍,有很多书籍,或者网上的文章大概都是这样介绍的:

1、堆是线程共享的内存区域,栈是线程独享的内存区域。2、堆中主要存放对象实例,栈中主要存放各种基本数据类型、对象的引用。

但是,作者可以很负责任的告诉大家,以上两个结论均不是完全正确的。

在我之前的文章《Java堆内存是线程共享的!面试官:你确定吗?》中,介绍过了关于堆内存并不是完完全全的线程共享有关的知识点,本文就第二个话题来探讨一下。

对象内存分配

在《Java虚拟机规范》中,关于堆有这样的描述:

在Java虚拟机中,堆是可供各个线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。

一个Java对象在堆上分配的时候,主要是在Eden区上,如果启动了TLAB的话会优先在TLAB上分配,少数情况下也可能会直接分配在老年代中,分配规则并不是百分之百固定的,这取决于当前使用的是哪一种垃圾收集器,还有虚拟机中与内存有关的参数的设置。

但是一般情况下是遵循以下原则的:

对象优先在Eden区分配优先在Eden分配,如果Eden没有足够空间,会触发一次Monitor GC大对象直接进入老年代需要大量连续内存空间的Java对象,当对象需要的内存大于-XX:PretenureSizeThreshold参数的值时,对象会直接在老年代分配内存。但是,虽然虚拟机规范中是有着这样的要求,但是各个虚拟机厂商在实现虚拟机的时候,可能会针对对象的内存分配做一些优化。这其中最典型的就是HotSpot虚拟机中的JIT技术的成熟,使得对象在堆上分配内存并不是一定的。

其实在《深入理解Java虚拟机》中,作者也提出过类似的观点,因为JIT技术的成熟使得"对象在堆上分配内存"就不是那么绝对的了。但是书中并没有展开介绍到底什么是JIT,也没有介绍JIT优化到底做了什么。那么接下来我们就来深入了解一下:

JIT 技术

我们大家都知道,通过 javac 将可以将Java程序源代码编译,转换成 java 字节码,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。这就是传统的JVM的解释器(Interpreter)的功能。很显然,Java编译器经过解释执行,其执行速度必然会比直接执行可执行的二进制字节码慢很多。为了解决这种效率问题,引入了 JIT(Just In Time ,即时编译) 技术。

有了JIT技术之后,Java程序还是通过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

热点检测

上面我们说过,要想触发JIT,首先需要识别出热点代码。目前主要的热点代码识别方式是热点探测(Hot Spot Detection),HotSpot虚拟机中采用的主要是基于计数器的热点探测

基于计数器的热点探测(Counter Based Hot Spot Detection)。采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。

编译优化

JIT在做了热点检测识别出热点代码后,除了会对其字节码进行缓存,还会对代码做各种优化。这些优化中,比较重要的几个有:逃逸分析、 锁消除、 锁膨胀、 方法内联、 空值检查消除、 类型检测消除、 公共子表达式消除等。

而这些优化中的逃逸分析就和本文要介绍的内容有关了。

逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

例如:

sb是一个方法内部变量,上述代码中并没有将他直接返回,这样这个StringBuffer有不会被其他方法所改变,这样它的作用域就只是在方法内部。我们就可以说这个变量并没有逃逸到方法外部。

有了逃逸分析,我们可以判断出一个方法中的变量是否有可能被其他线程所访问或者改变,那么基于这个特性,JIT就可以做一些优化:

同步省略标量替换栈上分配关于同步省略,大家可百度搜索关于锁消除技术的介绍。本文主要来分析下标量替换和栈上分配。

标量替换、栈上分配

我们说,JIT经过逃逸分析之后,如果发现某个对象并没有逃逸到方法体之外的话,就可能对其进行优化,而这一优化最大的结果就是可能改变Java对象都是在堆上分配内存的这一原则。

对象要分配在堆上其实有很多原因,但是有一点比较关键的和本文有关的,那就是因为堆内存在访问上是线程共享的,这样一个线程创建出来的对象,其他线程也能访问到。

那么,试想下,如果我们在某一个方法体内部创建了一个对象,并且对象并没有逃逸到方法外的话,那还有必要一定要把对象分配到堆上吗?

其实就没有必要了,因为这个对象并不会被其他线程所访问到,生命周期也只是在一个方法内部,也就不用大费周折的在堆上分配内存,也减少了内存回收的必要。

那么,有了逃逸分析之后,发现一个对象并没有逃逸到放法外的话,通过什么办法可以进行优化,减少对象在堆上分配可能呢?

这就是栈上分配。在HotSopt中,栈上分配并没有正在的进行实现,而是通过标量替换来实现的。

所以我们重点介绍下,什么是标量替换,如何通过标量替换实现栈上分配。

标量替换

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

以上代码中,point对象并没有逃逸出alloc方法,并且point对象是可以拆解成标量的。那么,JIT就会不会直接创建Point对象,而是直接使用两个标量int x ,int y来替代Point对象。

可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。

通过标量替换,原本的一个对象,被替换成了多个成员变量。而原本需要在堆上分配的内存,也就不再需要了,完全可以在本地方法栈中完成对成员变量的内存分配。

实验证明

Talk Is Cheap, Show Me The Code

No Data, No BB;

接下来我们就来通过一个实验,来看一下逃逸分析是否可以生效,生效后是否真的会发生栈上分配,而栈上分配又有什么好处呢?

我们来看以下代码:

其实代码内容很简单,就是使用for循环,在代码中创建100万个User对象。

我们在alloc方法中定义了User对象,但是并没有在方法外部引用他。也就是说,这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化。

我们指定以下JVM参数并运行:

-Xmx4G-Xms4G-XX:-DoEscapeAnalysis-XX:+PrintGCDetails-XX:+HeapDumpOnOutOfMemoryError

其中-XX:-DoEscapeAnalysis表示关闭逃逸分析。

在程序打印出 cost XX ms 后,代码运行结束之前,我们使用jmap命令,来查看下当前堆内存中有多少个User对象:

从上面的jmap执行结果中我们可以看到,堆中共创建了100万个StackAllocTest$User实例。

在关闭逃避分析的情况下(-XX:-DoEscapeAnalysis),虽然在alloc方法中创建的User对象并没有逃逸到方法外部,但是还是被分配在堆内存中。也就说,如果没有JIT编译器优化,没有逃逸分析技术,正常情况下就应该是这样的。即所有对象都分配到堆内存中。

接下来,我们开启逃逸分析,再来执行下以上代码。

-Xmx4G-Xms4G-XX:+DoEscapeAnalysis-XX:+PrintGCDetails-XX:+HeapDumpOnOutOfMemoryError

在程序打印出 cost XX ms 后,代码运行结束之前,我们使用jmap命令,来查看下当前堆内存中有多少个User对象:

从以上打印结果中可以发现,开启了逃逸分析之后(-XX:+DoEscapeAnalysis),在堆内存中只有8万多个StackAllocTest$User对象。也就是说在经过JIT优化之后,堆内存中分配的对象数量,从100万降到了8万。

除了以上通过jmap验证对象个数的方法以外,读者还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少。

逃逸分析并不成熟

前面的例子中,开启逃逸分析之后,对象数目从100万变成了8万,但是并不是0,说明JIT优化并不会完完全全的所有情况都进行优化。

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。

总结

正常情况下,对象是要在堆上进行内存分配的,但是随着编译器优化技术的成熟,虽然虚拟机规范是这样要求的,但是具体实现上还是有些差别的。

如HotSpot虚拟机引入了JIT优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存。

所以,对象一定在堆上分配内存,这是不对的。

最后,我们留一个思考题:TLAB和栈上分配,大家觉得这两个优化有什么相同点和不同点吗?

JVM知识(一) 求你了,别再说Java对象都是在堆内存上分配空间的了!的更多相关文章

  1. 求你了,别再说Java对象都是在堆内存上分配空间的了!

    Java作为一种面向对象的,跨平台语言,其对象.内存等一直是比较难的知识点,所以,即使是一个Java的初学者,也一定或多或少的对JVM有一些了解.可以说,关于JVM的相关知识,基本是每个Java开发者 ...

  2. JVM探究 面试题 JVM的位置 三种JVM:HotSpot 新生区 Young/ New 养老区 Old 永久区 Perm 堆内存调优GC的算法有哪些?标记清除法,标记压缩,复制算法,引用计数法

    JVM探究 面试题: 请你弹弹你对JVM的理解?Java8虚拟机和之前的变化更新? 什么是OOM?什么是栈溢出StackOverFlowError?怎么分析 JVM的常用调优参数有哪些? 内存快照如何 ...

  3. JVM知识(四):GC配置参数

    JVM配置参数分为三类参数:跟踪参数.堆分配参数.栈分配参数 这三类参数分别用于跟踪监控JVM状态,分配堆内存以及分配栈内存. 跟踪参数 跟踪参数用户跟踪监控JVM,往往被开发人员用于JVM调优以及故 ...

  4. 【JVM从小白学成大佬】2.Java虚拟机运行时数据区

    目录 1.运行时数据区介绍 2.堆(Heap) 是否可能有两个对象共用一段内存的事故? 3.方法区(Method Area) 4.程序计数器(Program Counter Register) 5.虚 ...

  5. JVM垃圾回收机制总结(6) :透视Java的GC特性

    1. 使用 System.gc() 可以不管JVM使用的是哪一种垃圾回收的算法,都可以请求 Java的垃圾回收. 在命令行中有一个参数-verbosegc可以查看Java使用的堆内存的情况,它的格式: ...

  6. JVM中堆内存和栈内存的区别

    Java把内存分成两种,一种叫做栈内存,一种叫做堆内存 在函数中定义的一些基本类型的变量和对象的引用变量都是在函数的栈内存中分配.当在一段代码块中定义一个变量时,java就在栈中为这个变量分配内存空间 ...

  7. Java对象的创建 —— new之后JVM都做了什么?

    Java对象创建过程 1. 类加载检查 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载.解析和初始化过.如果没 ...

  8. JDK8中JVM堆内存划分

    一:JVM中内存 JVM中内存通常划分为两个部分,分别为堆内存与栈内存,栈内存主要用运行线程方法 存放本地暂时变量与线程中方法运行时候须要的引用对象地址. JVM全部的对象信息都 存放在堆内存中.相比 ...

  9. 1 - JVM随笔分类(java虚拟机的内存区域分配(一个不断记录和推翻以及再记录的一个过程))

    java虚拟机的内存区域分配   在JVM运行时,类加载器ClassLoader在加载到类的字节码后,交由jvm的执行引擎处理, 执行过程中需要空间来存储数据(类似于Cpu及主存),此时的这段空间的分 ...

随机推荐

  1. 痞子衡嵌入式:介绍i.MXRT定时器PIT的多通道链接模式及其在coremark测试工程里的应用

    大家好,我是痞子衡,是正经搞技术的痞子.今天痞子衡给大家介绍的是i.MXRT定时器PIT的多通道链接模式及其在coremark测试里的应用. 早在 2018 年 i.MXRT 系列跨界处理器刚推出的时 ...

  2. [Java]Java中的自动包装

    来源:https://www.cnblogs.com/cheapcrook/archive/2012/04/25/2470478.html 自动拆装箱(AutoBoxing) 是JDK1.5中新增加的 ...

  3. [题解]Codeforces Round #254 (Div. 2) B - DZY Loves Chemistry

    链接:http://codeforces.com/contest/445/problem/B 描述:n种药品,m个反应关系,按照一定顺序放进试管中.如果当前放入的药品与试管中的药品要反应,危险系数变为 ...

  4. Kubernetes云供应商架构的未来

    首先,我想分享SIG的使命,因为我们用它来指导我们现在和将来的工作.从我们的章程中直接来看,SIG的使命是简化,开发和维护云供应商集成,作为Kubernetes集群的扩展或附加组件.这背后的动机是双重 ...

  5. linux shell脚本批量修改密码,无需交互输入

    转至:https://blog.csdn.net/weixin_34409357/article/details/89833777?utm_medium=distribute.pc_relevant. ...

  6. $_SERVER["QUERY_STRING"],$_SERVER["REQUEST_URI"],$_SERVER["SCRIPT_NAME"] 和$_SERVER["PHP_SELF"]

    $_SERVER["QUERY_STRING"],$_SERVER["REQUEST_URI"],$_SERVER["SCRIPT_NAME" ...

  7. Java:代码改进技巧

    1.类名首字母大写:方法名首字母小写:常量名全大写: 2.当控制语句只有一句时,可以省略大括号{}:但是,建议任何时候都保留大括号,因为这是Java语句块的标志 3.用某个接口承接实现类时(多态),之 ...

  8. 串口通讯之rs232 c++版本

    rs232.cpp #ifndef kranfix_rs232_rs232_cc #define kranfix_rs232_rs232_cc #include "rs232.h" ...

  9. VM虚拟机 Ubuntu配置与ssh连接

    VMware安装ubuntu 自定义 不作更改 选择稍后安装操作系统,相当于裸机,没装系统. 选择ubuntu64 选择虚拟机名字与保存路径 配置情况 2G即可 网络类型,选择NAT 可以了解一下这几 ...

  10. LeetCode-036-有效的数独

    有效的数独 题目描述:请你判断一个 9x9 的数独是否有效.只需要 根据以下规则 ,验证已经填入的数字是否有效即可. 数字 1-9 在每一行只能出现一次. 数字 1-9 在每一列只能出现一次. 数字 ...