本系列文章主要是汇总了一下大佬们的技术文章,属于Android基础部分,作为一名合格的安卓开发工程师,咱们肯定要熟练掌握java和android,本期就来说说这些~
[如有侵权,请告知我,我会删除]
DD一下: Android进阶开发各类文档,也可关注公众号<Android苦做舟>获取。
1.Android高级开发工程师必备基础技能
2.Android性能优化核心知识笔记
3.Android+音视频进阶开发面试题冲刺合集
4.Android 音视频开发入门到实战学习手册
5.Android Framework精编内核解析
6.Flutter实战进阶技术手册
7.近百个Android录播视频+音视频视频dome
.......
在 JVM 中,Java对象保存在堆中时,由以下三部分组成:
对象头分为Mark Word(标记字)和Class Pointer(类指针),如果是数组对象还得再加一项Array Length(数组长度)。
Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。我们打开openjdk的源码包,对应路径/openjdk/hotspot/src/share/vm/oops,Mark Word对应到C++的代码markOop.hpp,可以从注释中看到它们的组成,本文所有代码是基于Jdk1.8。
在64位JVM中是这么存的:
虽然它们在不同位数的JVM中长度不一样,但是基本组成内容是一致的。
Klass Pointer
即类型指针,是0对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据
如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如boolean类型占1个字节,int类型占4个字节等等;
对齐数据
对象可以有对齐数据也可以没有。默认情况下,Java虚拟机堆中对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N个字节则需要对其填充,以此来补齐对象头和实例数据占用内存之后剩余的空间大小。如果对象头和实例数据已经占满了JVM所分配的内存空间,那么就不用再进行对齐填充了。
所有的对象分配的字节总SIZE需要是8的倍数,如果前面的对象头和实例数据占用的总SIZE不满足要求,则通过对齐数据来填满。
为什么要对齐数据?字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该
字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址。
至此,我们已经了解了对象在堆内存中的整体结构布局,如下图所示
JVM内存结构、Java对象模型和Java内存模型,这就是三个截然不同的概念,而这三个概念很容易混淆。这里详细区别一下
我们都知道,Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。其中有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。
在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域结构如下:
JVM内存结构,由Java虚拟机规范定义。描述的是Java程序执行过程中,由JVM管理的不同数据区域。各个区域有其特定的功能。
为了提高运算效率,就对空间进行了不同区域的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
JVM 的内存划分:
栈内存:存放的是方法中的局部变量,方法的运行一定要在栈当中,方法中的局部变量才会在栈中创建。
成员变量在堆内存,静态变量在方法区。
方法区:存储.class相关信息。
与开发相关的时方法栈、方法区、堆内存。
new出来的都放在堆内存!堆内存里面的东西都有一个地址值。方法进入方法栈中执行。
JVM堆内存分为年轻代和老年代。
Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。
HotSpot虚拟机中(Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机),设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。
每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个 instanceKlass 对象,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc 对象,这个对象中包含了对象头以及实例数据。对象的引用在方法栈中。
这就是一个简单的 Java对象的OOP-Klass模型,即Java对象模型。
Java内存模型就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。
JSR-133: Java Memory Model and Thread Specification中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。
简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。
JMM 线程操作内存的基本的规则:
第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行,不能直接从主内存中读写。
第二条关于线程间本地内存:不同线程之间无法直接访问其他线程本地内存中的变量,线程间变量值的传递需要经过主内存来完成。
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。(主内存中的数据)由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
本地内存
主要存储当前方法的所有本地变量信息(本地内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的本地内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
在开始之前,首先介绍一下HSDB工具使用
如图所示
进入对应的JDK-Lib目录,然后输入java -cp .sa-jdi.jar sun.jvm.hotspot.HSDB 就会出现HSDB窗体应用程序
然后运行对应的Demo代码
public class HSDBTest {
public HSDBTest() {
}
public static void main(String[] args) {
Teacher kerwin = new Teacher();
kerwin.setName("kerwin");
for(int i = 0; i < 15; ++i) {
System.gc();
}
Teacher jett = new Teacher();
jett.setName("jett");
StackTest test = new StackTest();
test.test1(1);
System.out.println("挂起....");
try {
Thread.sleep(10000000L);
} catch (InterruptedException var5) {
var5.printStackTrace();
}
}
}
开启新的dos命令
如图所示
当运行成功后,在对应HSDB应用上输入对应的进程号就能看到对应进程的加载情况!
如图所示
如果说对应的HSDB一直出现加载情况,那么就得查看打开HSDB对应的dos命令页面上是否报错。
如果说报 UnsatisfiedLinkError异常
那么说明:JDK目录中缺失sawindbg.dll文件
如图所示
此时,就需要把自己其中jrein目录下sawindbg.dll 粘贴到另一个jrein 目录下,然后关闭HSDB,再次打开既ok
如图所示
在这里选择对应的main线程,Stack Memory 就能看到对应Stack详细信息!
如图所示
打开对应的Tools -heap parametes 就能看到对应的年轻代,老年代对应的起始点!
如图所示
从这两张图可知:年轻代里面包含Eden区,From区和To区,对应的内存地址块都在年轻代范围内!
OK!到这里,相信你对 年轻代和老年代里面具体划分有了一定的认知!!!
那么!年轻代和老年代它们之间是怎么运作的呢?为什么年轻代要分为Eden、From、To三个模块呢?
因此迎来了本篇重点:对象的分配过程,前面都是引子!
那么堆是什么呢?
那么堆的对象分配、管理又是怎么的呢?
如图所示
至于为什么要这样分配,这就和分代相互关联了!
那么!为什么要分代(年轻代和老年代)呢?
如图所示
默认空间大小:
那么如何查看本机空间大小呢?
public class EdenSurvivorTest {
public static void main(String[] args) {
EdenSurvivorTest test = new EdenSurvivorTest();
test.method1();
// test.method2();
}
/**
* 堆内存大小示例
* 默认空间大小:
* 初始大小:物理电脑内存大小 / 64
* 最大内存大小:物理电脑内存大小 / 4
*/
public void method1(){
long initialMemory = Runtime.getRuntime().totalMemory();
long maxMemory = Runtime.getRuntime().maxMemory();
System.out.println("初始内存:"+(initialMemory / 1024 / 1024));
System.out.println("最大内存:"+(maxMemory / 1024 / 1024));
try {
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果
初始内存:245
最大内存:3621
当然也可以使用jstat命令查看
如图所示
这里简单的提一下这里面的类型表示什么意思,更多jstat命令查看
到这里才开始讲解本篇的重点
注意:Java 阈值是15,Android阈值是6,这里就拿Android举例
如图所示
所有变量的产生都在Eden区,当Eden区满了时,将会触发minorGC
如图所示
当minorGC 触发后,不需要的变量将会被回收掉,正在使用中的变量将会移动至From区,并且对应的阈值+1
如图所示
当下一次Eden区满了后,对应minorGC,将会带同From区、Eden区一起,标记对象
如图所示
回收成功后,对应的From区以及Eden区,正在使用的的都会进入To区,对应阈值+1
同理,当下一次Eden满了后,对应To区和Eden区都会被对应minorGC标记,正在使用中的对象又全部移动至From区,一直来回交替!对应的阈值也会自增
如图所示
当对应的From区或者To区存在未回收的对象的阈值满足进入老年代条件时,对应的对象将会移动至老年代!
当然在老年代里面,如果内存满了,也会触发Full GC,未被回收的对象阈值+1
为了加深印象,这里用一段小故事来描述整段过程!
这就是一整段很标准的内存分配过程,那么如果存在特殊情况将会是怎样的呢?
比如说,产生的对象Eden直接装不下的那种
如图所示
进入老年代的方式有四种方式:
验证对象分配过程
短生命周期分配过程
说了这么多,来验证一把哇
public class EdenSurvivorTest {
public static void main(String[] args) {
EdenSurvivorTest test = new EdenSurvivorTest();
test.method2();
}
public void method2(){
ArrayList list = new ArrayList();
for (;;) {
TestGC t = new TestGC();
// list.add(t);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
这里我们大概分析下代码,在for死循环里,对象TestGC 生命周期仅限于当前循环里,属于短生命周期对象,那么我们来看看具体是对象是如何分配的!
如图所示
打开JDK-BIN 目录,然后双击对应的exe
注意:
一切准备就绪后,运行上面代码,然后打开该exe,就能看到
如图所示
图里面该说的都说了,不过注意的是,这里OLD区并没有任何数据!
因为在上面代码解析的时候就已经说了,产生的对象生命周期仅限于For循环里,并非长生命周期对象
那么能否举一个有长生命周期对象的例子呢?
长生命周期分配过程
public class EdenSurvivorTest {
public static void main(String[] args) {
EdenSurvivorTest test = new EdenSurvivorTest();
test.method2();
}
public void method2(){
ArrayList list = new ArrayList();
for (;;) {
TestGC t = new TestGC();
list.add(t);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
运行该代码,然后再次查看刚刚的Exe
如图所示
因为对应变量的生命周期不再仅限于for内部,因此当阈值满足老年代要求时,将直接进入老年代
如图所示
因为老年代里面的对象一直持有,并没有未使用的对象,当老年代满了时,就会触发OOM异常!!
在上面提到过好几个GC,那么不同的GC有什么区别呢?
MinorGc/MajorGC/FullGC的区别
JVM在进行GC时,并非每次都对上面三个内存区域一起回收,大部分的只会针对于Eden区进行 在JVM标准中,他里面的GC按照回收区域划分为两种:
年轻代触发机制
老年代GC触发机制:
FullGC触发
Full GC 是开发或者调优中尽量要避开的
GC日志查看
如图所示
在这里添加:-Xms9m -Xmx9m -XX:+PrintGCDetails 提交后,再次运行代码:
Connected to the target VM, address: '127.0.0.1:53687', transport: 'socket'
[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->740K(9728K), 0.0032500 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2291K->504K(2560K)] 2544K->2280K(9728K), 0.0040878 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2343K->504K(2560K)] 4120K->4104K(9728K), 0.0010760 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2341K->504K(2560K)] 5942K->5912K(9728K), 0.0013867 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 504K->0K(2560K)] [ParOldGen: 5408K->5741K(7168K)] 5912K->5741K(9728K), [Metaspace: 3336K->3336K(1056768K)], 0.0044415 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[Full GC (Ergonomics) [PSYoungGen: 1859K->600K(2560K)] [ParOldGen: 5741K->6941K(7168K)] 7601K->7541K(9728K), [Metaspace: 3336K->3336K(1056768K)], 0.0042249 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 1836K->1800K(2560K)] [ParOldGen: 6941K->6941K(7168K)] 8778K->8742K(9728K), [Metaspace: 3336K->3336K(1056768K)], 0.0018656 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 1800K->1800K(2560K)] [ParOldGen: 6941K->6925K(7168K)] 8742K->8725K(9728K), [Metaspace: 3336K->3336K(1056768K)], 0.0043790 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 2560K, used 1907K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048K, 93% used [0x00000000ffd00000,0x00000000ffedcfd8,0x00000000fff00000)
from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen total 7168K, used 6925K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 96% used [0x00000000ff600000,0x00000000ffcc3688,0x00000000ffd00000)
Metaspace used 3369K, capacity 4556K, committed 4864K, reserved 1056768K
class space used 364K, capacity 392K, committed 512K, reserved 1048576K
就能查看对应的GC日志了。
A a = new A();
创建过程
当遇到关键字new指令时,Java对象创建过程便开始,整个过程如下:
Java对象创建过程
下面我将对每个步骤进行讲解。
过程步骤
步骤1:类加载检查
步骤2:为对象分配内存
方式1:指针碰撞
方式2:空闲列表
额外知识
特别注意
所以,给对象分配内存会存在线程不安全的问题。
解决 线程不安全 有两种方案:
步骤3: 将内存空间初始化为零值
内存分配完成后,虚拟机需要将分配到的内存空间初始化为零(不包括对象头)
步骤4: 对对象进行必要的设置
这些信息存放在对象的对象头中。
总结
下面用一张图总结 Java对象创建的过程
下面我会详细说明每一块区域。
此处存储的信息包括两部分:
特别注意
如果对象 是 数组,那么在对象头中还必须有一块用于记录数组长度的数据
// HotSpot虚拟机默认的分配策略如下:
longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)
// 从分配策略中可以看出,相同宽度的字段总是被分配到一起
// 在满足这个前提的条件下,父类中定义的变量会出现在子类之前
CompactFields = true;
// 如果 CompactFields 参数值为true,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。
由于引用类型数据(reference)在 Java虚拟机中只规定了一个指向对象的引用,但没定义该引用应该通过何种方式去定位、访问堆中的对象的具体位置
所以对象访问方式取决于虚拟机实现。目前主流的对象访问方式有两种:
具体请看如下介绍:
JIT 即时编译还有一个最前沿的优化技术:逃逸分析(Escape Analysis) 。废话少说,我们直接步入正题吧。
首先我们需要知道,逃逸分析并不是直接的优化手段,而是通过动态分析对象的作用域,为其它优化手段提供依据的分析技术。具体而言就是:
逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。Java虚拟机的即时编译器会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。即时编译器判断对象是否逃逸的依据有两种:
方法逃逸我们可以用个案例来演示一下:
//StringBuffer对象发生了方法逃逸
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
public static String createString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
关于逃逸分析技术,本人想过用代码展示对象是否发生了逃逸,比如说上述代码,根据理论知识可以认为 createStringBuffer 方法中发生了逃逸,但是具体是个什么情况,咱们都不清楚。虽然 JVM 有个参数 PrintEscapeAnalysis 可以显示分析结果,但是该参数仅限于 debug 版本的 JDK 才可以进行调试,多次尝试后,未能编译出 debug 版本的 JDK,暂且没什么思路,所以查看逃逸分析结果这件事先往后放一放,后续学习 JVM 调优再进一步来学习。
即时编译器可以根据逃逸分析的结果进行诸如同步消除、栈上分配以及标量替换的优化。
同步消除(锁消除)
线程同步本身比较耗费资源,JIT 编译器可以借助逃逸分析来判断,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks(默认开启)可以开启同步消除。 这个取消同步的过程就叫同步消除,也叫锁消除。
我们还是通过案例来说明这一情况,来看看何种情况需要线程同步。
首先构建一个 Worker 对象
@Getter
public class Worker {
private String name;
private double money;
public Worker() {
}
public Worker(String name) {
this.name = name;
}
public void makeMoney() {
money++;
}
}
测试代码如下:
public class SynchronizedTest {
public static void work(Worker worker) {
worker.makeMoney();
}
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
Worker worker = new Worker("hresh");
new Thread(() -> {
for (int i = 0; i < 20000; i++) {
work(worker);
}
}, "A").start();
new Thread(() -> {
for (int i = 0; i < 20000; i++) {
work(worker);
}
}, "B").start();
long end = System.currentTimeMillis();
System.out.println(end - start);
Thread.sleep(100);
System.out.println(worker.getName() + "总共赚了" + worker.getMoney());
}
}
执行结果如下:
52
hresh总共赚了28224.0
可以看出,上述两个线程同时修改同一个 Worker 对象的 money 数据,对于 money 字段的读写发生了竞争,导致最后结果不正确。像上述这种情况,即时编译器经过逃逸分析后认定对象发生了逃逸,那么肯定不能进行同步消除优化。
换个对象不发生逃逸的情况试一下。
//JVM参数:-Xms60M -Xmx60M -XX:+PrintGCDetails -XX:+PrintGCDateStamps
public class SynchronizedTest {
public static void lockTest() {
Worker worker = new Worker();
synchronized (worker) {
worker.makeMoney();
}
}
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
new Thread(() -> {
for (int i = 0; i < 500000; i++) {
lockTest();
}
}, "A").start();
new Thread(() -> {
for (int i = 0; i < 500000; i++) {
lockTest();
}
}, "B").start();
long end = System.currentTimeMillis();
System.out.println(end - start);
}
}
输出结果如下:
56
Heap
PSYoungGen total 17920K, used 9554K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 15360K, 62% used [0x00000007bec00000,0x00000007bf5548a8,0x00000007bfb00000)
from space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
to space 2560K, 0% used [0x00000007bfb00000,0x00000007bfb00000,0x00000007bfd80000)
ParOldGen total 40960K, used 0K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
object space 40960K, 0% used [0x00000007bc400000,0x00000007bc400000,0x00000007bec00000)
Metaspace used 4157K, capacity 4720K, committed 4992K, reserved 1056768K
class space used 467K, capacity 534K, committed 640K, reserved 1048576K
在 lockTest 方法中针对新建的 Worker 对象加锁,并没有实际意义,经过逃逸分析后认定对象未逃逸,则会进行同步消除优化。JDK8 默认开启逃逸分析,我们尝试关闭它,再看看输出结果。
-Xms60M -Xmx60M -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+PrintGCDateStamps
输出结果变为:
73
2022-03-01T14:51:08.825-0800: [GC (Allocation Failure) [PSYoungGen: 15360K->1439K(17920K)] 15360K->1447K(58880K), 0.0018940 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 17920K, used 16340K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 15360K, 97% used [0x00000007bec00000,0x00000007bfa8d210,0x00000007bfb00000)
from space 2560K, 56% used [0x00000007bfb00000,0x00000007bfc67f00,0x00000007bfd80000)
to space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
ParOldGen total 40960K, used 8K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
object space 40960K, 0% used [0x00000007bc400000,0x00000007bc402000,0x00000007bec00000)
Metaspace used 4153K, capacity 4688K, committed 4864K, reserved 1056768K
class space used 466K, capacity 502K, committed 512K, reserved 1048576K
经过对比发现,关闭逃逸分析后,执行时间变长,且内存占用变大,同时发生了垃圾回收。
不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁,如上述案例所示,lockTest 方法中的加锁操作没什么意义。
事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。
标量替换
在讲解 Java 对象的内存布局时提到过,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程大都是可见的(除开 TLAB)。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。
如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。
但是目前 Hotspot 并没有实现真正意义上的栈上分配,而是使用了标量替换这么一项技术。
所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。
若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解, 那它就被称为聚合量(Aggregate),Java 中的对象就是典型的聚合量。
标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。
如下述案例所示:
public class ScalarTest {
public static double getMoney() {
Worker worker = new Worker();
worker.setMoney(100.0);
return worker.getMoney() + 20;
}
public static void main(String[] args) {
getMoney();
}
}
经过逃逸分析,Worker 对象未逃逸出 getMoney()的调用,因此可以对聚合量 worker 进行分解,得到局部变量 money,进行标量替换后的伪代码:
public class ScalarTest {
public static double getMoney() {
double money = 100.0;
return money + 20;
}
public static void main(String[] args) {
getMoney();
}
}
对象拆分后,对象的成员变量改为方法的局部变量,这些字段既可以存储在栈上,也可以直接存储在寄存器中。标量替换因为不必创建对象,减轻了垃圾回收的压力。
另外,可以手动通过-XX:+EliminateAllocations可以开启标量替换(默认是开启的), -XX:+PrintEliminateAllocations(同样需要debug版本的JDK)查看标量替换情况。
栈上分配
故名思议就是在栈上分配对象,其实目前 Hotspot 并没有实现真正意义上的栈上分配,实际上是标量替换。
在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着 JIT 编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否需要创建对象,是否可以将堆内存分配转换为栈内存分配。
C2 的逃逸分析与控制流无关,相对来说比较简单。Graal 则引入了一个与控制流有关的逃逸分析,名为部分逃逸分析(partial escape analysis)。它解决了所新建的实例仅在部分程序路径中逃逸的情况。
如下代码所示:
public static void bar(boolean cond) {
Object foo = new Object();
if (cond) {
foo.hashCode();
}
}
// 可以手工优化为:
public static void bar(boolean cond) {
if (cond) {
Object foo = new Object();
foo.hashCode();
}
}
假设 if 语句的条件成立的可能性只有 1%,那么在 99% 的情况下,程序没有必要新建对象。其手工优化的版本正是部分逃逸分析想要自动达到的成果。
部分逃逸分析将根据控制流信息,判断出新建对象仅在部分分支中逃逸,并且将对象的新建操作推延至对象逃逸的分支中。这将使得原本因对象逃逸而无法避免的新建对象操作,不再出现在只执行 if-else 分支的程序路径之中。
我们通过一个完整的测试案例来间接验证这一优化。
public class PartialEscapeTest {
long placeHolder0;
long placeHolder1;
long placeHolder2;
long placeHolder3;
long placeHolder4;
long placeHolder5;
long placeHolder6;
long placeHolder7;
long placeHolder8;
long placeHolder9;
long placeHoldera;
long placeHolderb;
long placeHolderc;
long placeHolderd;
long placeHoldere;
long placeHolderf;
public static void foo(boolean flag) {
PartialEscapeTest o = new PartialEscapeTest();
if (flag) {
o.hashCode();
}
}
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
foo(false);
}
}
}
本次测试选用的是 JDK11,开启 Graal 编译器需要配置如下参数:
-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler
分别输出使用 C2 编译器或 Graal 编译器的 GC 日志,对应命令为:
java -Xlog:gc* PartialEscapeTest
java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler -Xlog:gc* PartialEscapeTest
通过对比 GC 日志可以发现内存占用情况不一致,Graal 编译器下内存占用更小一点。
C2
[0.012s][info][gc,heap] Heap region size: 1M
[0.017s][info][gc ] Using G1
[0.017s][info][gc,heap,coops] Heap address: 0x0000000700000000, size: 4096 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
[0.345s][info][gc,heap,exit ] Heap
[0.345s][info][gc,heap,exit ] garbage-first heap total 262144K, used 21504K [0x0000000700000000, 0x0000000800000000)[0.345s][info][gc,heap,exit ] region size 1024K, 18 young (18432K), 0 survivors (0K)
[0.345s][info][gc,heap,exit ] Metaspace used 6391K, capacity 6449K, committed 6784K, reserved 1056768K
[0.345s][info][gc,heap,exit ] class space used 552K, capacity 571K, committed 640K, reserved 1048576K
Graal
[0.019s][info][gc,heap] Heap region size: 1M
[0.025s][info][gc ] Using G1
[0.025s][info][gc,heap,coops] Heap address: 0x0000000700000000, size: 4096 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
[0.611s][info][gc,start ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[0.612s][info][gc,task ] GC(0) Using 6 workers of 10 for evacuation
[0.615s][info][gc,phases ] GC(0) Pre Evacuate Collection Set: 0.0ms
[0.615s][info][gc,phases ] GC(0) Evacuate Collection Set: 3.1ms
[0.615s][info][gc,phases ] GC(0) Post Evacuate Collection Set: 0.2ms
[0.615s][info][gc,phases ] GC(0) Other: 0.6ms
[0.615s][info][gc,heap ] GC(0) Eden regions: 24->0(150)
[0.615s][info][gc,heap ] GC(0) Survivor regions: 0->3(3)
[0.615s][info][gc,heap ] GC(0) Old regions: 0->4
[0.615s][info][gc,heap ] GC(0) Humongous regions: 5->5
[0.615s][info][gc,metaspace ] GC(0) Metaspace: 8327K->8327K(1056768K)
[0.615s][info][gc ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 29M->11M(256M) 3.941ms
[0.615s][info][gc,cpu ] GC(0) User=0.01s Sys=0.01s Real=0.00s
Cannot use JVMCI compiler: No JVMCI compiler found
[0.616s][info][gc,heap,exit ] Heap
[0.616s][info][gc,heap,exit ] garbage-first heap total 262144K, used 17234K [0x0000000700000000, 0x0000000800000000)
[0.616s][info][gc,heap,exit ] region size 1024K, 9 young (9216K), 3 survivors (3072K)
[0.616s][info][gc,heap,exit ] Metaspace used 8336K, capacity 8498K, committed 8832K, reserved 1056768K
[0.616s][info][gc,heap,exit ] class space used 768K, capacity 802K, committed 896K, reserved 1048576K
查看 Graal 在 JDK11 上的编译结果,可以执行下述命令:
java -XX:+PrintCompilation -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler -cp /Users/xxx/IdeaProjects/java_deep_learning/src/main/java/com/msdn/java/javac/escape ScalarTest > out-jvmci.txt
GC类型 | GC区域 | 触发条件 | Stop The World时间 |
Minor GC | Eden 和 Survivor 区域 | Eden区域 > 设定内存阈值 | 对于大部分应用程序,**Minor GC停顿导致的延迟都是可以忽略不计的。**大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 Survivor 区或者老年代空间。如果Eden 区大部分新生对象不符合 GC 条件,Minor GC 执行时暂停的时间将会长很多。 |
Major GC | Old区域 | 根据不同的GC配置由Minor GC触发 | MajorGC 的速度一般会比 Minor GC 慢 10倍以上。 |
Full GC | 整个Heap空间包括年轻代和永久代 | 1. 调用System.gc时 | Old老年代空间不足;方法区空间不足;通过Minor GC后进入老年代的平均大小大于老年代的可用内存 。 |
GC 日志是由 JVM 产生的对垃圾回收活动进行描述的日志文件。
通过 GC 日志,我们能够直观的看到内存回收的情况及过程,是能够快速判断内存是否存在故障的重要依据。
在 JAVA 命令中增加 GC 相关的参数,以生成 GC日志:
JVM 参数 | 参数说明 | 备注 |
-XX:+PrintGC | 打印 GC 日志 | |
-XX:+PrintGCDetails | 打印详细的 GC 日志 | 配置此参数时,-XX:+PrintGC 可省略 |
-XX:+PrintGCTimeStamps | 以基准形式记录时间(即启动后多少秒,如:21.148:) | 默认的时间记录方式,可省略 |
-XX:+PrintGCDateStamps | 以日期形式记录时间(如:2022-05-27T18:01:37.545+0800: 30.122:) | 当以日期形式记录时间时,日期后其实还带有基准形式的时间 |
-XX:+PrintHeapAtGC | 打印堆的详细信息 | |
-Xloggc:gc.log | 配置 GC 日志文件的路径 |
常用的选项组合:
java -Xms512m -Xmx2g -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -jar xxx.jar
前文我们介绍了通过 -XX:+UseSerialGC 、 -XX:+UseParallelGC (同 -XX:+UseParallelOldGC )、 -XX:+UseParNewGC 、 -XX:+UseConcMarkSweepGC 、 -XX:+UseG1GC 这些参数,来指定使用不同的垃圾回收器组合。不同的垃圾回收器所生成的 GC 日志也是有差异的,尤其是 CMS 、 G1 所生成的日志,会比 Serial 、Parallel 所生成的日志复杂许多。
这里我们以 JDK1.8 默认使用的 -XX:+UseParallelGC (即 Parallel Scavenge + Parallel Old )为例,讲解其 GC 日志。
开头部分的环境信息
上图是 GC 日志的开头部分:
Young GC
上图描述的是 Young GC 活动:
Full GC
上图描述的是 Full GC 活动:
以上便是 JDK1.8 下 -XX:+UseParallelGC (即 Parallel Scavenge + Parallel Old )模式下, GC 日志的详细解读。不同的模式下的日志,对于新生代、老年代的别称也是不同的,我们将上一篇文章中的一张特征信息总结表格拿过来,再次加深一下印象:
JVM 参数 | 日志中对新生代的别称 | 日志中对老年代的别称 |
-XX:+UseSerialGC | DefNew | Tenured |
-XX:+UseParallelGC | PSYoungGen | ParOldGen |
-XX:+UseParallelOldGC | PSYoungGen | ParOldGen |
-XX:+UseParNewGC | ParNew | Tenured |
-XX:+UseConcMarkSweepGC | ParNew | CMS |
-XX:+UseG1GC | 没有专门的新生代描绘,有 Eden 和 Survivors | 没有专门的老年代描绘,有 Heap |
接下来我们需要配合一些图形化的工具来分析 GC 日志。
GCeasy 是笔者最为推荐的 GC 日志分析工具,它是一个在线工具,号称业界第一个以机器学习算法为基础的 GC 日志分析工具。它不仅能够生成多元化的分析图例(这是免费的),还能够推荐 JVM 配置、提出存在的内存问题并推荐对应的解决方案(后面这些功能是付费的)。
我们来看一下免费的功能:
GCViewer 是一个离线的 GC 可视化分析工具,在同类离线工具中,可以说是功能最为强大的了。
GChisto 也是一个离线工具,功能相较于 GCViewer 显得比较简单,下载地址:github.com/jewes/gchis…
GCLogViewer 也是一个离线工具,官方地址国内无法打开(code.google.com/p/gclogview…),想要下载的话可以到 CSDN 上找一找。
其功能与 GChisto 比较相似,效果图如下: