堆是分配对象的唯一选择么?

逃逸分析

在《深入理解 Java 虚拟机》中关于 Java 堆内存有这样一段描述:

随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将
会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,
有一种特殊情况,那就是如果经过逃逸分析( Escape Analysis)后发现,一个对象
并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内
存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

此外,前面提到的基于 openJDk 深度定制的 TaoBaovm,其中创新的 GCIH( GC
invisible heap)技术实现 off-heap,将生命周期较长的 Java 对象从 heap 中移至

heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率
和提升 GC 的回收效率的目的。

如何将堆上的对象分配到栈,需要使用逃逸分析手段。

这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据
流分析算法。通过逃逸分析, Java Hotspot 编译器能够分析出一个新的对象的引用
的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析
对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如 作为调用参数传递到其他地方中。

逃逸分析举例

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除,
每个栈里面包含了很多栈帧,也就是发生逃逸分析

public void my_method ( ) { 

	 V v = new V( ) ; 
	
	 // use v 
	
	 // . . . . 
	
	 v = null; 

} 

针对下面的代码

public static StringBuffer createStringBuffer(String s1, String s2) { 

	 StringBuffer sb = new StringBuffer( ) ; 
	
	 sb . append ( s1) ; 
	
	 sb . append ( s2) ; 
	
	 return sb; 

} 

如果想要 StringBuffer sb 不发生逃逸,可以这样写

public static String createStringBuffer(String s1, String s2) { 

	 StringBuffer sb = new StringBuffer( ) ; 
	
	 sb . append ( s1) ; 
	
	 sb . append ( s2) ; 
	
	 return sb . toString( ) ; 

} 

完整的逃逸分析代码举例

** 
/** 
 * 逃逸分析 
 * 如何快速的判断是否发生了逃逸分析,大家就看 new 的对象是否在方法外被调用。 
 * @author : zzp
 * @create : 2022	
 */ 

public class EscapeAnalysis { 

	 public EscapeAnalysis obj ; 

 /** 
 * 方法返回 EscapeAnalysis 对象,发生逃逸 
 * @return 
 */ 
 public EscapeAnalysis getInstance( ) { 
	 return obj == null ? new EscapeAnalysis ( ) : obj ; 
 } 
 

 /** 
 * 为成员属性赋值,发生逃逸 
 */ 
 public void setObj ( ) { 
	 this . obj = new EscapeAnalysis ( ) ; 
 } 
 

 /** 
 *对象的作用于仅在当前方法中有效,没有发生逃逸 
 */ 
 public void useEscapeAnalysis ( ) { 
	 EscapeAnalysis e = new EscapeAnalysis ( ) ; 
 } 
 
 /** 
 * 引用成员变量的值,发生逃逸 
 */ 
 public void useEscapeAnalysis2( ) { 
 	EscapeAnalysis e = getInstance ( ) ; 
	 // getInstance() .XXX 发生逃逸 
 } 
} 

参数设置

在 JDK 1.7 版本之后, HotSpot 中默认就已经开启了逃逸分析

如果使用的是较早的版本,开发人员则可以通过:

  • 选项“-xx: +DoEscapeAnalysis"显式开启逃逸分析
  • 通过选项“-xx: +PrintEscapeAnalysis"查看逃逸分析的筛选结果

结论

开发中能使用局部变量的,就不要使用在方法外定义。

使用逃逸分析,编译器可以对代码做如下优化:

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指 向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上
    分配
  • 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的 操作可以不考虑同步。
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可 以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU 寄存器中。

栈上分配

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法
的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结
束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

常见的栈上分配的场景

在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。

举例
我们通过举例来说明 开启逃逸分析 和 未开启逃逸分析时候的情况

/** 	
 * 栈上分配 	
 * -Xmx1G -Xms1G -XX: -DoEscapeAnalysis -XX: +PrintGCDetails 
 * @author :zzp 
 * @create : 2022
 */ 
class User { 
	 private String name; 	
	 private String age; 	
	 private String gender; 	
	 private String phone; 

} 

public class StackAllocation { 	
 public static void main (String [ ] args ) throws InterruptedException 
{ 
	 long start = System . currentTimeMillis ( ) ; 		
	 for ( int i = 0; i < 100000000; i++) { 	
	 alloc ( ) ; 	
 } 

	 long end = System . currentTimeMillis ( ) ; 
	 System . out . println ( "花费的时间为: " + (end - start ) + " ms " ) ; 
 	// 为了方便查看堆内存中对象个数,线程 sleep 
	 Thread . sleep(10000000) ; 	
 } 
 
 private static void alloc ( ) { 	
	 // 未发生逃逸 
	 User user = new User( ) ; 
} 	
}

设置 JVM 参数,表示未开启逃逸分析

-Xmx1G -Xms1G -XX : -DoEscapeAnalysis -XX : +PrintGCDetails

运行结果,同时还触发了 GC 操作

花费的时间为: 664 ms

然后查看内存的情况,发现有大量的 User 存储在堆中
在这里插入图片描述
我们在开启逃逸分析

-Xmx1G -Xms1G -XX : +DoEscapeAnalysis -XX : +PrintGCDetails

然后查看运行时间,我们能够发现花费的时间快速减少,同时不会发生 GC 操作

花费的时间为: 5 ms

在看内存情况,我们发现只有很少的 User 对象,说明 User 未发生逃逸,因为它
存储在栈中,随着栈的销毁而消失
在这里插入图片描述

同步省略

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候, JIT 编译器可以借助逃逸分析来判断同步块所使用的锁
对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编
译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并
发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。

例如下面的代码

public void f( ) { 

 Object hellis = new Object ( ) ; 

	 synchronized (hellis ) { 
	
	 System . out . println (hellis ) ; 
	
	 } 	
} 

代码中对 hellis 这个对象加锁,但是 hellis 对象的生命周期只在 f()方法中,并不
会被其他线程所访问到,所以在 JIT 编译阶段就会被优化掉,优化成:

public void f( ) { 

	 Object hellis = new Object ( ) ; 
	
	 System . out . println (hellis ) ; 

} 

我们将其转换成字节码
在这里插入图片描述

分离对象和标量替换

标量(scalar)是指一个无法再分解成更小的数据的数据。 Java 中的原始数据类型
就是标量。

相对的,那些还可以分解的数据叫做聚合量(Aggregate), Java 中的对象就是聚
合量,因为他可以分解成其他聚合量和标量。

在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过

JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个
过程就是标量替换。

public static void main (String args [ ] ) { 
 alloc ( ) ; 

} 

class Point { 

 private int x; 

 private int y; 

} 

private static void alloc ( ) { 

 Point point = new Point (1, 2) ; 

 System . out . println ( "point . x" + point . x + " ; point . y" + point . y) ; 
}                

以上代码,经过标量替换后,就会变成

private static void alloc ( ) { 
	 int x = 1; 
	 int y = 2; 
	 System . out . println ( "point . x = " + x + " ; point . y=" + y) ; 
} 

可以看到, Point 这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两
个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一
旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供
了很好的基础。

代码优化之标量替换

上述代码在主函数中进行了 1 亿次 alloc。调用进行对象创建,由于 User 对象实例
需要占据约 16 字节的空间,因此累计分配空间达到将近 1.5GB。如果堆空间小于
这个值,就必然会发生 GC。使用如下参数运行上述代码:

  • server -Xmx100m -Xms100m -XX : +DoEscapeAnalysis -XX : +PrintGC -XX : +Elimi
    nateAllocations

这里设置参数如下:
• 参数-server:启动 Server 模式,因为在 server 模式下,才可以启用逃逸分析。
• 参数-XX:+DoEscapeAnalysis:启用逃逸分析
• 参数-Xmx10m:指定了堆空间最大为 10MB
• 参数-XX:+PrintGC:将打印 Gc 日志
• 参数一 xx: +EliminateAllocations:开启了标量替换(默认打开),允许将对
象打散分配在栈上,比如对象拥有 id 和 name 两个字段,那么这两个字段将
会被视为两个独立的局部变量进行分配

逃逸分析的不足

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

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸
分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系
列复杂的分析的,这其实也是一个相对耗时的过程。 一个极端的例子,就是经过
逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费
掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手
段。注意到有一些观点,认为通过逃逸分析, JVM 会在栈上分配那些不会逃逸的
对象,这在理论上是可行的,但是取决于 JvM 设计者的选择。据我所知, oracle
Hotspot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以
明确所有的对象实例都是创建在堆上。

目前很多书籍还是基于 JDK7 以前的版本, JDK 已经发生了很大变化, intern 字符
串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但
是, intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,
所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值