重学Android基础系列篇(五):Android虚拟机类和对象的结构

虚拟机类和对象的结构1.即类型指针,是0对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。JVM内存结构,由Java虚拟机规范定义。虚拟机是否使用TLAB,可以通过-XX:+/-参数来设定。内存分配完成后,虚拟机需

1.Android高级开发工程师必备基础技能2.Android性能优化核心知识笔记3.Android+音视频进阶开发面试题冲刺合集4.Android 音视频开发入门到实战学习手册5.Android Framework精编内核解析6.Flutter实战进阶技术手册7.近百个Android录播视频+音视频视频dome.......

虚拟机类和对象的结构1.对象内存结构

在 JVM 中,Java对象保存在堆中时,由以下三部分组成:

对象头分为Mark Word(标记字)和Class (类指针),如果是数组对象还得再加一项Array (数组长度)。

Mark Word

用于存储对象自身的运行时数据,如哈希码()、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。

Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。我们打开的源码包,对应路径///src/share/vm/oops,Mark Word对应到C++的代码.hpp,可以从注释中看到它们的组成,本文所有代码是基于Jdk1.8。

在64位JVM中是这么存的:

虽然它们在不同位数的JVM中长度不一样,但是基本组成内容是一致的。

Klass

即类型指针,是0对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据

如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如类型占1个字节,int类型占4个字节等等;

对齐数据

对象可以有对齐数据也可以没有。默认情况下,Java虚拟机堆中对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N个字节则需要对其填充,以此来补齐对象头和实例数据占用内存之后剩余的空间大小。如果对象头和实例数据已经占满了JVM所分配的内存空间,那么就不用再进行对齐填充了。

所有的对象分配的字节总SIZE需要是8的倍数,如果前面的对象头和实例数据占用的总SIZE不满足要求,则通过对齐数据来填满。

为什么要对齐数据?字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该

字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址。

至此,我们已经了解了对象在堆内存中的整体结构布局,如下图所示

2. JVM内存结构、Java对象模型和Java内存模型区别

JVM内存结构、Java对象模型和Java内存模型,这就是三个截然不同的概念,而这三个概念很容易混淆。这里详细区别一下

2.1 JVM内存结构(5个部分)

我们都知道,Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。其中有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。

在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域结构如下:

JVM内存结构,由Java虚拟机规范定义。描述的是Java程序执行过程中,由JVM管理的不同数据区域。各个区域有其特定的功能。

为了提高运算效率,就对空间进行了不同区域的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。

JVM 的内存划分:

栈内存:存放的是方法中的局部变量,方法的运行一定要在栈当中,方法中的局部变量才会在栈中创建。

成员变量在堆内存,静态变量在方法区。

方法区:存储.class相关信息。

与开发相关的时方法栈、方法区、堆内存。

new出来的都放在堆内存!堆内存里面的东西都有一个地址值。方法进入方法栈中执行。

JVM堆内存分为年轻代和老年代。

2.2 Java对象模型()

Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。

虚拟机中(Sun JDK和中所带的虚拟机,也是目前使用范围最广的Java虚拟机),设计了一个OOP-Klass Model。OOP( )指的是普通对象指针,而Klass用来描述对象实例的具体类型。

每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个 对象,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个 对象,这个对象中包含了对象头以及实例数据。对象的引用在方法栈中。

这就是一个简单的 Java对象的OOP-Klass模型,即Java对象模型。

2.3 java内存模型

Java内存模型就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

Java内存模型是根据英文Java Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。

JSR-133: Java Model and 中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。

简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是、等关键字。

JMM 线程操作内存的基本的规则:

第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行,不能直接从主内存中读写。

第二条关于线程间本地内存:不同线程之间无法直接访问其他线程本地内存中的变量,线程间变量值的传递需要经过主内存来完成。

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。(主内存中的数据)由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

本地内存

主要存储当前方法的所有本地变量信息(本地内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的本地内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

3.堆内管理策略3.1 对象分配过程完全解析

在开始之前,首先介绍一下HSDB工具使用

3.1.1 HSDB工具应用

如图所示

进入对应的JDK-Lib目录,然后输入java -cp .sa-jdi.jar sun.jvm..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命令页面上是否报错。

如果说报 异常

那么说明:JDK目录中缺失.dll文件

如图所示

此时,就需要把自己其中jrebin目录下.dll 粘贴到另一个jrebin 目录下,然后关闭HSDB,再次打开既ok

如图所示

在这里选择对应的main线程,Stack 就能看到对应Stack详细信息!

如图所示

打开对应的Tools -heap 就能看到对应的年轻代,老年代对应的起始点!

如图所示

从这两张图可知:年轻代里面包含Eden区,From区和To区,对应的内存地址块都在年轻代范围内!

OK!到这里,相信你对 年轻代和老年代里面具体划分有了一定的认知!!!

那么!年轻代和老年代它们之间是怎么运作的呢?为什么年轻代要分为Eden、From、To三个模块呢?

因此迎来了本篇重点:对象的分配过程,前面都是引子!

3.1.2 堆的核心结构解析

那么堆是什么呢?

堆概述:一个JVM进程存在一个堆内存,堆是JVM内存管理的核心区域java 堆区在JVM启动是被创建,其空间大小也被确定,是JVM管理的 最大一块内存(堆内存大小可以调整)本质上堆是一组在物理上不连续的内存空间,但是逻辑上是连续的 空间(参考上面HSDB分析的内存结构)所有线程共享堆,但是堆内对于线程处理还是做了一个线程私有的 部分(TLAB)

那么堆的对象分配、管理又是怎么的呢?

堆的对象管理堆的内存细分

如图所示

至于为什么要这样分配,这就和分代相互关联了!

那么!为什么要分代(年轻代和老年代)呢?

分代思想不同对象的生命周期不一致,但是在具体使用过程中70%- 90的对象是临时对象分代唯一的理由是优化GC性能。如果没有分代,那么所有对象在一块空间,GC想要回收扫描他就必须扫描所有的对象,分代之后,长期持有的对象可以挑出,短期持有的对象可以固定在一个位置进行回收,省掉很 大一部分空间利用

如图所示

堆的默认大小

默认空间大小:

那么如何查看本机空间大小呢?

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命令查看

3.1.3 对象分配过程

到这里才开始讲解本篇的重点

注意:Java 阈值是15,阈值是6,这里就拿举例

正常分配过程

如图所示

所有变量的产生都在Eden区,当Eden区满了时,将会触发

如图所示

当 触发后,不需要的变量将会被回收掉,正在使用中的变量将会移动至From区,并且对应的阈值+1

如图所示

当下一次Eden区满了后,对应,将会带同From区、Eden区一起,标记对象

如图所示

回收成功后,对应的From区以及Eden区,正在使用的的都会进入To区,对应阈值+1

同理,当下一次Eden满了后,对应To区和Eden区都会被对应标记,正在使用中的对象又全部移动至From区,一直来回交替!对应的阈值也会自增

如图所示

当对应的From区或者To区存在未回收的对象的阈值满足进入老年代条件时,对应的对象将会移动至老年代!

当然在老年代里面,如果内存满了,也会触发Full GC,未被回收的对象阈值+1

为了加深印象,这里用一段小故事来描述整段过程!

我是一个普通的java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们 在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了区的“From”区,自从去了区, 我就开始了我漂泊的人生,有时候在的“From”区,有时候在的“To”区,居无定所直到我18岁(阈值达到老年代)的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代 里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次 GC加一岁),然后被回收。

这就是一整段很标准的内存分配过程,那么如果存在特殊情况将会是怎样的呢?

比如说,产生的对象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死循环里,对象 生命周期仅限于当前循环里,属于短生命周期对象,那么我们来看看具体是对象是如何分配的!

如图所示

打开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有什么区别呢?

//的区别

JVM在进行GC时,并非每次都对上面三个内存区域一起回收,大部分的只会针对于Eden区进行 在JVM标准中,他里面的GC按照回收区域划分为两种:

GC触发策略

年轻代触发机制

老年代GC触发机制:

触发

Full GC 是开发或者调优中尽量要避开的

GC日志查看

如图所示

在这里添加:-Xms9m -Xmx9m -XX:+ 提交后,再次运行代码:

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日志了。

3.2 对象创建过程解析3.2.1 对象创建

A a = new A();

限于普通对象,不包括数组和Class对象等

创建过程

当遇到关键字new指令时,Java对象创建过程便开始,整个过程如下:

Java对象创建过程

下面我将对每个步骤进行讲解。

过程步骤

步骤1:类加载检查

检查 该new指令的参数 是否能在 常量池中 定位到一个类的符号引用检查 该类符号引用 代表的类是否已被加载、解析和初始化过

步骤2:为对象分配内存

对象所需内存的大小在类加载完成后便可完全确定

Java堆内存 规整:已使用的内存在一边,未使用内存在另一边Java堆内存 不规整:已使用的内存和未使用内存相互交错

方式1:指针碰撞

方式2:空闲列表

额外知识

如、垃圾收集器

使用基于 算法的垃圾收集器时,采用空闲列表。

如 CMS垃圾收集器

特别注意

如,正在给对象A分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针来分配内存

所以,给对象分配内存会存在线程不安全的问题。

解决 线程不安全 有两种方案:

重学Android基础系列篇(五):Android虚拟机类和对象的结构

同步处理分配内存空间的行为

虚拟机采用 CAS + 失败重试的方式 保证更新操作的原子性

把内存分配行为 按照线程 划分在不同的内存空间进行

即每个线程在 Java堆中预先分配一小块内存(本地线程分配缓冲( Local ,TLAB)),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时才需要同步锁。虚拟机是否使用TLAB,可以通过-XX:+/-参数来设定。

步骤3: 将内存空间初始化为零值

内存分配完成后,虚拟机需要将分配到的内存空间初始化为零(不包括对象头)

保证了对象的实例字段在使用时可不赋初始值就直接使用(对应值 = 0)如使用本地线程分配缓冲(TLAB),这一工作过程也可以提前至TLAB分配时进行。

步骤4: 对对象进行必要的设置

如,设置 这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

这些信息存放在对象的对象头中。

总结

下面用一张图总结 Java对象创建的过程

3.3 对象的内存布局

下面我会详细说明每一块区域。

3.2.1 对象头 区域

此处存储的信息包括两部分:

如哈希码()、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等该部分数据被设计成1个 非固定的数据结构 以便在极小的空间存储尽量多的信息(会根据对象状态复用存储空间)

即对象指向它的类元数据的指针虚拟机通过这个指针来确定这个对象是哪个类的实例

特别注意

如果对象 是 数组,那么在对象头中还必须有一块用于记录数组长度的数据

因为虚拟机可以通过普通Java对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小。

3.2.2 实例数据 区域

即代码中定义的字段内容

// HotSpot虚拟机默认的分配策略如下:longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)// 从分配策略中可以看出,相同宽度的字段总是被分配到一起// 在满足这个前提的条件下,父类中定义的变量会出现在子类之前CompactFields = true;// 如果 CompactFields 参数值为true,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

3.2.3 对齐填充 区域

占位作用

3.2.4 总结

3.4 对象的访问定位

实际上需访问的是 对象类型数据 & 对象实例数据

由于引用类型数据()在 Java虚拟机中只规定了一个指向对象的引用,但没定义该引用应该通过何种方式去定位、访问堆中的对象的具体位置

所以对象访问方式取决于虚拟机实现。目前主流的对象访问方式有两种:

具体请看如下介绍:

4.逃逸分析

JIT 即时编译还有一个最前沿的优化技术:逃逸分析( ) 。废话少说,我们直接步入正题吧。

4.1 逃逸分析

首先我们需要知道,逃逸分析并不是直接的优化手段,而是通过动态分析对象的作用域,为其它优化手段提供依据的分析技术。具体而言就是:

逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。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();  }

关于逃逸分析技术,本人想过用代码展示对象是否发生了逃逸,比如说上述代码,根据理论知识可以认为 方法中发生了逃逸,但是具体是个什么情况,咱们都不清楚。虽然 JVM 有个参数 可以显示分析结果,但是该参数仅限于 debug 版本的 JDK 才可以进行调试,多次尝试后,未能编译出 debug 版本的 JDK,暂且没什么思路,所以查看逃逸分析结果这件事先往后放一放,后续学习 JVM 调优再进一步来学习。

4.2 基于逃逸分析的优化

即时编译器可以根据逃逸分析的结果进行诸如同步消除、栈上分配以及标量替换的优化。

同步消除(锁消除)

线程同步本身比较耗费资源,JIT 编译器可以借助逃逸分析来判断,如果确定一个对象不会逃逸出线程kotlin使用for循环,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+(默认开启)可以开启同步消除。 这个取消同步的过程就叫同步消除,也叫锁消除。

我们还是通过案例来说明这一情况,来看看何种情况需要线程同步。

首先构建一个 对象

@Getterpublic 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  {      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());  }}

执行结果如下:

52hresh总共赚了28224.0

可以看出,上述两个线程同时修改同一个 对象的 money 数据,对于 money 字段的读写发生了竞争,导致最后结果不正确。像上述这种情况,即时编译器经过逃逸分析后认定对象发生了逃逸,那么肯定不能进行同步消除优化。

换个对象不发生逃逸的情况试一下。

//JVM参数:-Xms60M -Xmx60M  -XX:+PrintGCDetails -XX:+PrintGCDateStampspublic 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  {      for (int i = 0; i < 500000; i++) {        lockTest();      }    }, "B").start();    long end = System.currentTimeMillis();    System.out.println(end - start);  }}

输出结果如下:

56Heap 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

在 方法中针对新建的 对象加锁,并没有实际意义,经过逃逸分析后认定对象未逃逸,则会进行同步消除优化。JDK8 默认开启逃逸分析,我们尝试关闭它,再看看输出结果。

-Xms60M -Xmx60M  -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+PrintGCDateStamps

输出结果变为:

732022-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

经过对比发现,关闭逃逸分析后,执行时间变长,且内存占用变大,同时发生了垃圾回收。

不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁,如上述案例所示, 方法中的加锁操作没什么意义。

事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。

标量替换

在讲解 Java 对象的内存布局时提到过,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程大都是可见的(除开 TLAB)。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。

如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。

但是目前 并没有实现真正意义上的栈上分配,而是使用了标量替换这么一项技术。

所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。

若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解, 那它就被称为聚合量(),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();  }}

经过逃逸分析kotlin使用for循环, 对象未逃逸出 ()的调用,因此可以对聚合量 进行分解,得到局部变量 money,进行标量替换后的伪代码:

public class ScalarTest {  public static double getMoney() {    double money = 100.0;    return money + 20;  }  public static void main(String[] args) {    getMoney();  }}

对象拆分后,对象的成员变量改为方法的局部变量,这些字段既可以存储在栈上,也可以直接存储在寄存器中。标量替换因为不必创建对象,减轻了垃圾回收的压力。

另外,可以手动通过-XX:+可以开启标量替换(默认是开启的), -XX:+tions(同样需要debug版本的JDK)查看标量替换情况。

栈上分配

故名思议就是在栈上分配对象,其实目前 并没有实现真正意义上的栈上分配,实际上是标量替换。

在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着 JIT 编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否需要创建对象,是否可以将堆内存分配转换为栈内存分配。

4.3 部分逃逸分析

C2 的逃逸分析与控制流无关,相对来说比较简单。Graal 则引入了一个与控制流有关的逃逸分析,名为部分逃逸分析( )。它解决了所新建的实例仅在部分程序路径中逃逸的情况。

如下代码所示:

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* PartialEscapeTestjava -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.00sCannot 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

5.Minor GC、Major GC和Full GC对比与GC日志分析5.1 Minor GC、Major GC和Full GC对比

GC类型

GC区域

触发条件

Stop The World时间

Minor GC

Eden 和 区域

Eden区域 > 设定内存阈值

对于大部分应用程序,**Minor GC停顿导致的延迟都是可以忽略不计的。**大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 区或者老年代空间。如果Eden 区大部分新生对象不符合 GC 条件,Minor GC 执行时暂停的时间将会长很多。

Major GC

Old区域

根据不同的GC配置由Minor GC触发

的速度一般会比 Minor GC 慢 10倍以上。

Full GC

整个Heap空间包括年轻代和永久代

1. 调用.gc时

Old老年代空间不足;方法区空间不足;通过Minor GC后进入老年代的平均大小大于老年代的可用内存 。

5.2 GC日志分析5.2.1 GC 日志能帮我们做什么

GC 日志是由 JVM 产生的对垃圾回收活动进行描述的日志文件。

通过 GC 日志,我们能够直观的看到内存回收的情况及过程,是能够快速判断内存是否存在故障的重要依据。

5.2.2 如何生成 GC 日志

在 JAVA 命令中增加 GC 相关的参数,以生成 GC日志:

JVM 参数

参数说明

备注

-XX:+

打印 GC 日志

-XX:+

打印详细的 GC 日志

配置此参数时,-XX:+ 可省略

-XX:+

以基准形式记录时间(即启动后多少秒,如:21.148:)

默认的时间记录方式,可省略

-XX:+

以日期形式记录时间(如:2022-05-27T18:01:37.545+0800: 30.122:)

当以日期形式记录时间时,日期后其实还带有基准形式的时间

-XX:+

打印堆的详细信息

-:gc.log

配置 GC 日志文件的路径

常用的选项组合:

java -Xms512m -Xmx2g -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -jar xxx.jar

5.3 读懂 GC 日志

前文我们介绍了通过 -XX:+ 、 -XX:+ (同 -XX:+ )、 -XX:+ 、 -XX:+ 、 -XX:+ 这些参数,来指定使用不同的垃圾回收器组合。不同的垃圾回收器所生成的 GC 日志也是有差异的,尤其是 CMS 、 G1 所生成的日志,会比 、 所生成的日志复杂许多。

这里我们以 JDK1.8 默认使用的 -XX:+ (即 + Old )为例,讲解其 GC 日志。

开头部分的环境信息

上图是 GC 日志的开头部分:

第 1 部分是 Java 环境信息:第 2 部分是服务器内存信息:第 3 部分打印出与 GC 相关的 JVM 启动参数,其中:

Young GC

上图描述的是 Young GC 活动:

第 1 部分是日志时间:第 2 部分是 GC 的类型与发生 GC 的原因:第 3 部分是 GC 活动的详情:第 4 部分是 GC 耗时的详情:

Full GC

上图描述的是 Full GC 活动:

第 1 部分是日志时间,与 Minor GC 日志相同,不再赘述;第 2 部分是 GC 的类型与发生 GC 的原因:第 3 部分是 GC 活动的详情:第 4 部分是 GC 耗时的详情,与 Minor GC 日志相同,不再赘述。

以上便是 JDK1.8 下 -XX:+ (即 + Old )模式下, GC 日志的详细解读。不同的模式下的日志,对于新生代、老年代的别称也是不同的,我们将上一篇文章中的一张特征信息总结表格拿过来,再次加深一下印象:

JVM 参数

日志中对新生代的别称

日志中对老年代的别称

-XX:+

-XX:+

-XX:+

-XX:+

-XX:+

CMS

-XX:+

没有专门的新生代描绘,有 Eden 和

没有专门的老年代描绘,有 Heap

5.4 GC 日志的图形化分析工具

接下来我们需要配合一些图形化的工具来分析 GC 日志。

5.4.1

是笔者最为推荐的 GC 日志分析工具,它是一个在线工具,号称业界第一个以机器学习算法为基础的 GC 日志分析工具。它不仅能够生成多元化的分析图例(这是免费的),还能够推荐 JVM 配置、提出存在的内存问题并推荐对应的解决方案(后面这些功能是付费的)。

我们来看一下免费的功能:

5.4.2

是一个离线的 GC 可视化分析工具,在同类离线工具中,可以说是功能最为强大的了。

5.4.3

也是一个离线工具,功能相较于 显得比较简单,下载地址:/jewes/gchis…

5.4.4

也是一个离线工具,官方地址国内无法打开(/p/…),想要下载的话可以到 CSDN 上找一找。

其功能与 比较相似,效果图如下:

本文到此结束,希望对大家有所帮助。

本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至81118366@qq.com举报,一经查实,本站将立刻删除。发布者:简知小编,转载请注明出处:https://www.jianzixun.com/78194.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫

相关推荐

软文友链广告合作联系站长qq81118366