spice and wolfspice and wolf Be the One you wanna Be

运行时数据区

运行时数据区概述

JVM运行时数据区规范

分配JVM内存空间

  • 分配堆的大小
-Xms(堆的初始容量)
-Xmx(堆的最大容量)

-XX:InitialHeapSize=268435456
-XX:MaxHeapSize=4294967296

# 如果为了提高性能,可以考虑去浪费空间,就是将初始容量和最大容量相等
  • 分配方法区的大小
-XX:PermSize
   永久代的初始容量
-XX:MaxPermSize
   永久代的最大容量

-XX:MetaspaceSize
   元空间的初始大小,达到该值就会触发垃圾收集进行类型写在,通识GC会堆该值进行调整;如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize
   最大空间,默认时没有限制的

-XX:MinMetaspaceFreeRatio
   在GC之后,最小的metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾回收
-XX:MaxMetaspaceFreeRatio
   在GC之后,最大的metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾回收
  • 分配线程空间的大小
-Xss:
   为jvm启动的每个线程分配的内存大小,jdk1.4默认256K,jdk1.5+默认1M

方法区

方法区存储什么数据

类型信息、方法信息、字段信息、Code区、方法表、静态变量(1.7后存在堆中)、运行时常量池(1.7后存在堆中)、JIT编译之后的代码缓存。

永久代和元空间的区别是什么

  • jdk1.8之前使用的方法区实现是永久代,jdk1.8之后方法区实现是元空间。
  • 存储位置不同,永久代所使用的内存区域是JVM进程所使用的区域,它的大小受整个JVM的大小所限制。元空间所使用的内存区域是物理内存区域,只会受到物理内存大小的限制。
  • 存储内容不同,永久代存储的信息基本上就是上面方法区存储内容。元空间只存储类的元信息,而静态变量和运行时常量池都挪到堆中存储。

为什么要使用元空间来替换永久代?

  • 字符串存在永久代中,容易出现性能问题和永久代内存溢出。
  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 永久代会GC带来不必要的复杂度,并且回收效率低。
  • Oracle计划将HotSpot与JRockit合二为一。

方法异常演示

  • 类加载导致OOM异常
  • 字符串OOM异常

字符串常量池

字符串常量池概述

字符串常量池存储

字符串常量池案例

双引号创建
String str1 = "abc";
String str2 = "abc";
System.out.println(str1 == str2);

双引号形式创建字符串时,JVM会先去字符串常量池根据StringTable查找有没有对应的字符串,有则返回引用,没有则在字符串常量池中创建字符串对象,并在StringTable中增加一组key-value对,并返回引用。本例中,str1初始化时会在StringTable中增加一组”abc”的key-value对,然后返回字符串常量池中的对象引用,str2初始化时会在StringTable中找到匹配项,直接返回引用。

new创建
String str1 = "abc";
String str2 = new String("abc");
String str3 = new String("abc");
System.out.println(str1 == str2);
System.out.println(str2 == str3);

new形式创建字符串时,JVM会先去字符串常量池根据StringTable查找有没有对应的字符串,有则在字符串常量池外堆内创建一个字符串对象,并返回这个堆内字符串对象的引用;没有则在字符串常量池中创建字符串对象并增加对应StringTable项后,再在堆内创建一个字符串对象,并返回这个堆内字符串对象的引用。本例中,str1引用指向的字符串常量池中的字符串对象,而str2引用指向的堆内字符串对象,所以第一个结果是false,而str2和str3在堆内指向不同的字符串对象,所以第二个结果也是false。

编译优化
String str1 = "a" + "b";  // 编译优化后相当于 String str1 = "ab";
final String s = "a"; // 常量a
String str2 = s + "b"; // s是常量,在编译优化后相当于 String str2 = "ab";
System.out.println(str1 == "ab");
System.out.println(str2 == "ab");

两种情况都会进行编译优化,结果都是true。

方法创建
String str7 = "abc".substring(0, 2); // 相当于new String("ab")
System.out.println(str7 == "ab");

String str8 = "abc".toUpperCase(); // 相当于new String("ABC")
System.out.println(str8 == "ABC");

结果都为false。

String对象的intern()方法

作用
  • 返回stringtable中对应字符串对象的引用值
  • 如果stringtable中没有对应字符串对象记录,则动态添加字符串对象到stringtable中
  • (jdk1.7之前)如果stringtable中没有对应字符串对象记录,此时字符串常量池中没有对应字符串对象,堆中有对应字符串对象,则会将堆中的字符串对象复制到字符串常量池中,并基于复制的对象添加stringtable项。
案例
        // c指向的是字符串常量池中的world字符串对象地址,
        // 而c.intern()取得strintable表中的引用地址也是字符串常量池中的字符串对象地址,
        // 所以为true
        String c = "world";
        System.out.println(c.intern() == c); // true

        // d创建时,会同时在字符串常量池中和堆中创建mike字符串对象,而d指向的则是堆中的字符串对象地址,
        // 而d.intern()会在stringtable中查找匹配项,而stringtable中匹配项的引用地址则是字符串常量池中的字符串对象地址,
        // 所以为false
        String d = new String("mike");
        System.out.println(d.intern() == d); // false

        // e创建时,因为没有john常量,所以编译期间不会将john加入到字符串常量池中,所以在字符串常量池中没有john对象
        // 而运行期间会在堆中创建john字符串对象
        // 这时stringtable中没有john对应的字符串对象记录,所以e.intern()会在把堆中的john字符串对象加入添加到stringtable中
        // 所以结果为true
        String e = new String("jo") + new String("hn");
        System.out.println(e.intern() == e); // true

        // 因为java为特殊字符,所以在程序运行初,java就被加载到stringtable中了
        // 所以这里是false
        String f = new String("ja") + new String("va");
        System.out.println(f.intern() == f); // false

jdk1.7和jdk1.7之前intern()方法差别

        // jdk1.6结果是false,jdk1.7结果是true
        String s3 = new String("l") + new String("l");
        s3.intern();
        String s4 = "ll";
        System.out.println(s3 == s4);
使用场景
public class StringPoolTest2 {
    static final int MAX = 1000 * 10000;
    static final String[] arr = new String[MAX];

    public static void main(String[] args) throws IOException {
        Integer[] DB_DATA = new Integer[10];
        Random random = new Random(10 * 10000);
        for (int i = 0; i < DB_DATA.length; i++) {
            DB_DATA[i] = random.nextInt();
            System.out.println(DB_DATA);
        }

        for (int i = 0; i < MAX; i++) {
            // 使用intern()后,堆中创建的字符串对象实例数只有10个,节省了堆内存空间
            arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
            // 不使用intern(),堆中会反复创建1000*10000个字符串对象
//            arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
        }
        System.out.println("等待中...");
        System.in.read();
    }
}

java堆

堆内存分配

JAVA堆在JVM启动时创建,是通过垃圾收集器去实现内存分配的。它是虚拟机管理的最大的一块内存空间。也是垃圾回收的主要区域,而其主要采用分代回收算法

堆空间的参数设置
  • -XX:+PrintFlagsInitial: 查看所有参数的默认初始值
  • -XX:+PrintFlagsFinal: 查看最终值(初始值可能被修改掉)
  • -Xms:初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间比例
  • -XX:MaxTenuringThreshold设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的GC处理日志
  • -XX:PrintGC -verbose:gc:打印GC简要信息
  • -XX:HandlePromotionFailure:是否设置空间担保
通过工具查看堆内存信息
  • jvisual vm工具
  • – XX:+PrintGCDetails虚拟机参数
  • jstat命令
  • jmap命令

对象内存分配

对象创建流程
Student student = new Student();
对象内存分配方式
分配方法说明收集器
指针碰撞内存地址是连续的(年轻代)Serial和ParNew
空闲列表内存地址不连续(年老代)CMS和Mark-Sweep
内存放配方式
对象内存分配安全问题

问题:在分配内存时,JVM虚拟机给A线程分配过程中,指针未修改前,线程B可能拿到同一块内存空间,导致写入问题。

解决方法:

  • 虚拟机采用CAS(乐观锁)和失败重试的方式保证更新操作的原子性。
  • TLAB本地线程分配缓冲。为每一个线程预先分配一块内存。

JVM在第一次给线程中的对象分配内存时,首先使用CAS进行TLAB的分配。当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。

对象分配(内存空间)过程
进入(晋升)老年代的条件
  • 存活年龄太大,默认大于15次(-XX:MaxTenuringThreshold)
  • 动态年龄判断。MinorGC后Survivor区域中一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代。
  • 大对象直接进入老年代。前提是Serial和ParNew收集器。
  • MinorGC后存活对象太多无法放入Survivor区。
  • 空间担保机制。
    • jdk1.6之前需要通过-XX:-HandlePromotionFailure设置才能起作用(1.6之后废弃)。只需要判断老年代可用空间>新生代GC之后对象总和,或者老年代可用空间>历次MinorGC升入老年代对象的平均大小。
    • 在MinorGC之前,判断老年代可用内存是否已经小于新生代全部对象大小,如果小于,继续判断,老年代大小是否小于历次MinorGC后进入老年代的对象的平均大小,如果都成功,冒险试一下MinorGC。
      • MinorGC后,剩余存活对象大小小于Survivor大小,直接进入Survivor区。
      • MinorGC后,剩余存活对象大小大于Survivor大小,但小于老年代剩余内存,直接进入老年代。
      • MinorGC后,剩余存活对象大小大于Survivor大小,也大于老年代可用内存,执行一次FGC。FGC后仍没有足够内存空间存放MinorGC的剩余对象,就会OOM。
空间担保机制

对象内存布局

对象内存布局分为三个部分:对象头、实例数据和对齐填充。

markword结构:

对象访问方式

方式优点
句柄稳定,对象被移动只需要修改句柄中的地址
直接指针访问速度快,节省了一次指针定位的开销
对象访问方式

直接内存

直接内存又叫堆外内存,它不是Java虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。

在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(channel)与缓冲区(buffer)的I/O方式,它可以使用native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

直接内存与堆内存比较

  • 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显。
  • 直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显。
  • 本机直接内存的分配不会受到Java堆大小的限制,受到本机总内存大小限制。
  • 配置虚拟机参数时,不要忽略直接内存防止出现OutOfMemoryError异常。

直接内存的实现

Java中分配堆外内存的方式有两种:

  • 通过ByteBuffer.java#allocationDirect得到以一个DirectByteBuffer对象。
  • 直接调用Unsafe.java#allocationMemory分配内存,但Unsafe只能在JDK的代码中调用,一般不会直接使用该方法分配内存。

其中DirectByteBuffer也是用Unsafe去实现内存分配的,对堆内存的分配、读写、回收都做了封装。

我们从堆外内存的分配回收、读写两个角度去分析DirectByteBuffer。

程序计数器(PC寄存器)

程序计数器的作用是存储按顺序执行的下一条指令地址。

事例

源代码:

public class PCRegisterTest {
    public static void main(String[] args) {
        int i = 100;
        int j = 200;
        int m = i + j;

        String str = "a";
        System.out.println(m);
        System.out.println(str);
    }
}

字节码:

 0 bipush 100
 2 istore_1
 3 sipush 200
 6 istore_2
 7 iload_1
 8 iload_2
 9 iadd
10 istore_3
11 ldc #2 <a>
13 astore 4
15 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
18 iload_3
19 invokevirtual #4 <java/io/PrintStream.println : (I)V>
22 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
25 aload 4
27 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
30 return

特点

  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
  • 在JVM规范中,每个线程都有它的程序计数器,是线程私有的,它的生命周期与线程的生命周期保持一致。
  • 任何时间一个线程都有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法JVM指令地址;或者,如果是执行native方法,则是未指定值(undefined)。
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要以来这个计数器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来读取下一条需要执行的字节码指令。
  • 它是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域。

面试题

  1. 并发与并行
  2. 为什么使用PC寄存器记录当前线程的执行地址
  3. PC寄存器为什么设定为线程私有

Java虚拟机栈(Java方法)

如何设置栈大小

使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。JDK5.0以后每个线程栈大小为1M,以前每个线程栈大小为256K。根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统堆一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

public class StackErrorTest {
    private static int count = 1;

    public static void main(String[] args) {
        System.out.println(count);
        count++;
        main(args);
    }
}

虚拟机栈存储哪些数据

栈帧是什么

栈帧是用于支持虚拟机进行方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应一个栈帧在虚拟机栈里从入栈到出栈的过程。

当前栈帧

一个线程中方法的调用链可能会很长,所以会有很多栈帧。只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关联的方法称为当前方法、定义这个方法的类叫做当前类。

执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。

什么时候创建栈帧

调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧,方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。

局部变量表
  • 静态方法的局部变量表:下标为0需要特殊留给某个引用。
  • 非静态方法的局部变量表:下标为0永远留给this引用。
存储内容

局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。
一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference和returnAddress类型的数据。reference类型表示对一个对象实例的引用。

局部变量表中的存储顺序:

  • this引用(实例对象都需要维护的一个变量,而且在局部变量表中始终处于第一个位置,也就是下标为0的位置)
  • 方法参数
  • 方法内声明的变量
存储容量

局部变量表的容量以变量槽为最小单位,Java虚拟机规范并没有定义一个槽所应占用的内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。
在Java程序编译为Class文件时,就在方法的Code属性中的mac_locals数据项中确定了该方法所需分配的局部变量表的最大容量。(最大Slot数量)
double\long这种8字节类型的数据、都需要两个slot来存储。
虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型)时,会连续使用两个连续的Slot来存储。

操作数栈

操作数栈也称为操作栈,它是一个后入先出栈。
JVM的解释引擎是基于(操作数栈)的方法去执行的。(另外还有一种是基于寄存器的方式)。
当一个方法刚刚开始执行时,其操作数栈是空的、随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段(成员变量)中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

存储内容

操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量、64位的数据类型占两个栈容量。

存储容量

同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。

动态链接

在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其他内存地址中的直接引用、而符号引用存在于方法区中的运行时常量池。
Java虚拟机栈中,每个栈帧都包含一个执行运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接。

  • 符号引用:
  • 直接引用。就是对应方法的内存地址。
方法返回

当一个方法开始执行时,可能有两种方式退出该方法:

  • 正常完成出口
  • 异常完成出口

栈运行原理

相关面试题

  1. 举例栈溢出的情况
    递归调用等。解决方法通过-Xss设置栈的大小。
  2. 调整栈的大小,就能保证不溢出吗?
    不能,如果递归无限次肯定会溢出,调整栈大小只能保证溢出的时间能晚一些,极限情况会导致OOM内存溢出
  3. 分配的栈内存越大越好吗?
    不会,会挤占其他线程的空间
  4. 垃圾回收是否会牵涉到虚拟机栈
    不会
  5. 方法中定义的局部变量是否线程安全
    具体情况具体分析

本地方法栈

什么是本地方法栈

本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务(字节码服务),而本地方法为虚拟机使用到的Native方法(比如C++方法)服务。简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。

在定义一个native method时,并不提供实现体(有些像定义一个java interface),因为其实现体是由非java语言在外面实现的。下面给了一个实例:

public class IHaveNative {
    native public void native1(int x);
    native static public long native2();
    native synchronized private float native3(Object o);
    native void native4(int[] array) throws Exception;
}

本地接口的作用是融合不同的编程语言为java所用,它的初衷是融合C/C++程序。

为什么要使用本地方法

有时java需要与java外的环境进行交互,这是本地方法存在的主要原因,如与操作系统交互时、与设备交互时、多线程操作线程唤醒和暂停时等。

本地方法栈的使用流程

一个线程调用java方法和本地方法时的栈

方法执行

字节码指令集

什么是字节码指令集

Java字节码指令由操作码和操作数构成。其中JVM规定操作码的长度为一个字节(0-255),也就是字节码的总量不会超过256个。
由于Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码。

实例:

iconst_0      操作码
bipush 10     操作码 + 操作数
都有哪些字节码指令
基本数据类型
  1. 除了long和double类型外,每个变量都占局部变量区中的一个变量槽,而long及double会占用两个连续的变量槽。
  2. 大多数对于boolean、byte、short和char类型数据的操作,都是用相应的int类型作为运算类型。
加载和存储指令(总)
  1. 将一个局部变量表加载到操作数栈
  2. 将一个数值从操作数栈存储到局部变量表
  3. 将一个常量加载到操作数栈。
  4. 扩充局部变量表的访问索引指令。
const系列(小数值)

该系列命令主要负责把简单的数值类型送到操作数栈栈顶。该系列命令不带参数。

push系列(中数值)

该系列命令主要负责把整形数字(长度比较小)送到操作数栈栈顶。该系列命令有一个参数。

ldc系列(大数值或字符串常量)

该系列命令负责把长度比较长的数值常量string常量值常量池中推送至操作数栈栈顶。该命令后面需要给一个表示常量在常量池中位置的参数。

load系列
  • load系列A。负责把本地变量表中的值送到操作数栈栈顶。这里的本地变量不仅可以是数值类型,还可以是引用类型。
  • load系列B。负责把数组的某项送到栈顶。该命令根据栈里内容来确定对那个数组的哪项进行操作。
store系列
  • store系列A。负责把操作数栈栈顶的值存入本地变量表。这里的本地变量不仅可以是数值类型,还可以是引用类型。
  • store系列B。负责把栈顶项的值存到数组里。根据栈里内容决定对哪个数组的那项进行操作。
pop系列

只是简单地对栈顶进行操作。

栈顶元素数学操作及移位操作系列

该系列命令负责

常见语法的字节码指令
同步锁
public class SyncTest {
    final Object lock = new Object();

    public void doLock() {
        synchronized (lock) {
            System.out.println("测试synchronized字节码");
        }
    }
}
 0 aload_0
 1 getfield #3 <SyncTest.lock : Ljava/lang/Object;>
 4 dup
 5 astore_1
 6 monitorenter
 7 getstatic #4 <java/lang/System.out : Ljava/io/PrintStream;>
10 ldc #5 <测试synchronized字节码>
12 invokevirtual #6 <java/io/PrintStream.println : (Ljava/lang/String;)V>
15 aload_1
16 monitorexit
17 goto 25 (+8)
20 astore_2
21 aload_1
22 monitorexit
23 aload_2
24 athrow
25 return
装箱和拆箱
public class BoxTest {
    public Integer cal() {
        Integer a = 1000;
        int b = a * 10;
        return b;
    }
}
 0 sipush 1000
 3 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
 6 astore_1
 7 aload_1
 8 invokevirtual #3 <java/lang/Integer.intValue : ()I>
11 bipush 10
13 imul
14 istore_2
15 iload_2
16 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
19 areturn
数组
public class ArrayTest {
    public int getValue() {
        int[] arr = new int[]{100, 200, 300, 400};
        return arr[2];
    }

    public int getLength(int[] arr) {
        return arr.length;
    }
}
 0 iconst_4
 1 newarray 10 (int)
 3 dup
 4 iconst_0
 5 bipush 100
 7 iastore
 8 dup
 9 iconst_1
10 sipush 200
13 iastore
14 dup
15 iconst_2
16 sipush 300
19 iastore
20 dup
21 iconst_3
22 sipush 400
25 iastore
26 astore_1
27 aload_1
28 iconst_2
29 iaload
30 ireturn

JVM程序执行流程

什么是即使编译器?

即时编译器(Just In Time Compiler),又称JIT编译器。
在部分商用虚拟机中(如HotSpot),Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码的运行特别频繁时,就会把这些代码认定为”热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器。
由于Java虚拟机规范并没有具体的约束规则去限制即时编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现相关的内容,如无特殊说明,我们提到的编译器、即时编译器都是指Hotspot虚拟机内的即时编译器,虚拟机也是特指Hotspot虚拟机。
我们的JIT是属于动态编译方式的,动态编译指的是”在运行时进行编译”;与之相对的是事前编译,也叫静态编译。

什么是热点代码?

运行过程中会被及时编译器编译的”热点代码”有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

两种情况,编译器都是以整个方法作为编译对象。这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为栈上替换,即方法栈帧还在栈上,方法就被替换了。

热点检测方式

目前主要的探测方式有两种:

  • 基于采样的热点探测。
  • 基于计数器的热点探测。

解释器执行

编译器执行之JIT使用

  • 为何HotSpot需要使用解释器和编译器并存的架构?
  • JVM为什么要实现两个不同的即时编译器?
  • 程序何时会使用解释器执行?何时会使用编译器执行?
  • 哪个程序代码会被编译成为本地代码?如何编译?
  • JAVA代码的执行效率就一定比C、C++静态执行的效率低?JAVA代码解析执行有何优势?

编译器执行之JIT优化

HotSpot虚拟机使用了很多种优化技术,这里只简单介绍其中的几种,完整的优化技术介绍可以参考官网内容。

公共子表达式的消除

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式E已经计算过了,并且从先前到现在E中所有变量的值都没有发生变化、那么E的这次出现就成为了公共子表达式。
对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
如果这种优化仅限于程序的基本块内,变成为局部公共子表达式消除。
如果这种优化范围涵盖了多个基本块,那就成为全局公共子表达式消除。

方法内联

在使用JIT进行即时编译时,将方法调用直接使用方法体中的代码进行替换,这就是方法内联,减少了方法调用过程中压栈与入栈的开销。同时为之后的一些优化手段提供条件。如果JVM检测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身。

方法逃逸分析

逃逸分析是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析、Java HotSpot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,成为方法逃逸。

逃逸分析包括:

  • 全局变量赋值逃逸
  • 方法返回值逃逸
  • 实例引用发生逃逸
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
对象的栈上内存分配

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

标量替换

标量是指一个无法再分解成更小的数据的数据。与之对应的有一个聚合量(对象)。
标量替换,其实指的就是将聚合量拆分为一个个的标量放入栈中。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。

案例:

// 有一个类A
public class A {
    public int a = 1;
    public int b = 2;
}

// 方法getAB()使用类A里面的AB
private void getAB() {
    A x = new A();
    x.a;
    x.b;
}

// JVM在编译时会直接编译成
private void getAB() {
    a = 1;
    b = 2;
}

// 这就是标量替换
同步锁消除

同样基于逃逸分析、当前锁的变量不会发生逃逸、是线程私有的完全没有必要加锁。在JIT编译时期就可以将同步锁去掉,以减少加锁与解锁造成的资源开销。

public class TestLockEliminate {
    public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }

    public static void main(String[] args) {
        long tsStart = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            getString("TestLockEliminate ", "Suffix");
        }
        System.out.println("一共耗费: " + (System.currentTimeMillis() - tsStart) + "ms");
    }
}

在开启逃逸分析(-XX:+DoEscapedAnalysis )的同时,通过改变同步锁消除(-XX:+EliminateLocks)的参数来测试运行时间。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Press ESC to close