编译JDK

  • Mac 配置:

  • Xcode配置:

  • Jdk源码下载:https://hg.openjdk.java.net/jdk/jdk12/file/06222165c35f

  • 安装JDK11:

  • 依赖环境
brew install ccache
brew install freetype
brew install autoconf
  • 编译
bash configure --with-debug-level=slowdebug --with-jvm-variants=server --enable-ccache --with-freetype=bundled --with-boot-jdk=/Library/Java/JavaVirtualMachines/jdk-11.0.8.jdk/Contents/Home --disable-warnings-as-errors

make images
  • 验证
cd /Users/zhangchi/Documents/workspaces/jdk12-06222165c35f/build/macosx-x86_64-server-slowdebug/jdk/bin

./java -version

  • idea绑定

内存运行模型


Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,仅在Java虚拟机退出时才被销毁。其他数据区域是每个线程的。在创建线程时创建每线程数据区域,在线程退出时销毁每个数据区域。

五大分区


Program Counter Register Java虚拟机可以一次支持多个执行线程。每个Java虚拟机线程都有其自己的pc(程序计数器)寄存器。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法。如果该方法不是本机方法,则pc寄存器包含当前正在执行的Java虚拟机指令的地址。如果线程当前正在执行的方法是本地方法,则Java虚拟机的pc寄存器的值未定义(Undefined)。

pc是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。该内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

public class JavaVirtualMachineMemoryModel {
    private int calculate() {
        int a = 1, b = 2, c = 10;
        return (a + b) * c;
    }
}

javac JavaVirtualMachineMemoryModel.java 
javap -p -l -c JavaVirtualMachineMemoryModel.class

红框中的编号就是字节码指令的偏移地址,pc中存储的就是当前方法的执行地址编号。

我们知道PC记录的字节码指令地址,但是native本地方法大多是通过C实现并未编译成需要执行的字节码指令所以在PC中当然是undefined。

那么native方法是如何实现多线程的呢?

Java线程总是需要以某种形式映射到OS线程上。映射模型可以是1:1(原生线程模型)、n:1(绿色线程 / 用户态线程模型)、m:n(混合模型)。以HotSpot VM的实现为例,它目前在大多数平台上都使用1:1模型,也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎样就是怎样。就像一个用C或C++写的多线程程序,它在线程切换的时候是怎样的,Java的native方法也就是怎样的。

Java Virtual Machine Stack 虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链表、方法出口等信息。每个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

在指定的控制线程中,同一时间只有一个栈帧(用于执行方法的帧)处于活动状态。该帧称为当前帧,其方法称为当前方法。定义当前方法的类是当前类。局部变量和操作数堆栈上的操作通常参考当前帧。如果当前方法调用另一个方法或该方法已完成,则该帧将不再是当前帧。调用方法时,将创建新的栈帧,并在控制权转移到新方法时变为新栈帧。在方法返回时,当前帧将其方法调用的结果(如果有的话)传递回前一帧。当前一个帧变为当前帧时,当前帧将被丢弃。

public class JavaVirtualMachineMemoryModel {
    private int calculate() {//栈帧calculate
        int a = 1, b = 2, c = 10;
        return (a + b) * c;
    }

    private void getResult(){//栈帧getResult
        System.out.println(calculate());
    }

    public static void main(String[] args) {//栈帧main
        JavaVirtualMachineMemoryModel main = new JavaVirtualMachineMemoryModel();
        main.getResult();
    }
}

对应的栈帧链路:

Local Variables(局部变量表)

每个栈帧均包含一个称为其局部变量表的变量数组。栈帧的局部变量数组的长度在编译时确定,并以类或接口的二进制表示形式以及与栈帧关联的方法的代码属性。局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、dubbo)、对象引用(reference)、字节码指令地址(returnAddress)。这些数据类型在局部变量表中的存储是以局部变量槽(slot)来表示的。其中64位长度的long和dubbo类型数据占用两个变量槽,其余数据类型只占用一个。

局部变量所需要的内存空间是在编译期间完成分配,即在 Java 程序编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。当进入一个方法时,这个方法需要在栈帧中分配的多大的局部变量空间是完全确定的,在方法执行期间不会改变局部变量表的大小,这里的“大小”指的是局部变量槽的数量,虚拟机真正使用多大的内存空间来实现一个变量槽,这是完全有具体的虚拟机实现自行决定的。
public class LocalVariables {
    public void lv(int a) {
        float f = 0;
        long l = 0;
        double d = 0;
        char c = 0;
        byte b = 1;
        short s = 2;
        Object o= new Object();
    }
}

1.Code: 标明保存在方法的code属性中

2.flags: (0x0001) ACC_PUBLIC:代表共有方法

3.stack=2, locals=11, args_size=2

stack:操作数栈的深度

locals:占用变量槽的大小,long和dubbo占用两个变量槽,其他属性占用一个变量槽,在加上一个this一共是11个变量槽。

args_size:代表方法参数的个数,默认有一个this占用一个变量槽。

4.fconst_0:将浮点常量0压入操作数堆栈。

5.fstore_2:将float存入局部变量,这里的2代表其所在当前栈帧的局部变量表的索引。

6.lconst_0:将长整形常量0压入操作数堆栈。

7.lstore_3:将long存入局部变量,这里的3代表其所在当前栈帧的局部变量表的索引。

8.dconst_0:将双精度常量0压入操作数堆栈。

9.dstore 5:将double存入局部变量,这里的5代表其所在当前栈帧的局部变量表的索引。

10.iconst_0:将字符型常量0压入操作数堆栈。

11.istore 7:将char存入局部变量,这里的7代表其所在当前栈帧的局部变量表的索引。

12.iconst_1:将字节型常量0压入操作数堆栈。

13.istore 8:将byte存入局部变量,这里的8代表其所在当前栈帧的局部变量表的索引。

14.iconst_2:将短整型常量0压入操作数堆栈。

15.istore 9:将short存入局部变量,这里的9代表其所在当前栈帧的局部变量表的索引。

16.return:标记void方法的返回。

17.new:创建一个对象。

18.dup:复制最高操作数堆栈值

19.invokespecial:初始化实例对象

20.astore 10:将引用存入局部变量,这里的10代表其所在当前栈帧的局部变量表的索引。

21.descriptor: (I)V:描述方法的入参类型为int。

代码中的Object = o,因为没有赋值所以不进行压栈赋值操作,只有创建了对象Object o = new Object(),才做入栈赋值操作。


Operand Stacks(操作数栈) JVM使用操作数堆栈作为工作空间,就像进行粗略的工作一样,或者可以说用于存储中间计算的结果。操作数堆栈被组织为单词数组,例如局部变量数组。但这不能通过使用像局部变量数组之类的索引来访问,而是由一些可以将值压入操作数堆栈的指令,某些可以从操作数堆栈弹出值的指令以及一些可以执行所需操作的指令来访问。例如:这是JVM使用下面的代码的方式,该代码将减去包含两个int的两个局部变量并将int结果存储在第三个局部变量中:

 public void subtraction() {
        int a = 50;
        int b = 20;
        int c = a - b;
    }

其中 iload_1、iload_2表示在当前栈的局部变量表中,将下标为1、2的两个数据压入操作数栈,isub弹出两个整数,减去它们并将结果压入操作数堆栈,istore_3代表将计算的最终结果赋值给下标为3的局部变量。

Dynamic Linking(动态链表) 动态链表又称指向运行时常量的方法引用。每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。在Java源文件在编译成字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。(Constant Pool Reference就是动态链表)

public class DynamicLinking {
   int num = 10;
   public void methodA(){
       System.out.println("methodA()....");
  }
   public void methodB(){
       System.out.println("methodB()....");
       methodA();
       num++;
  }
}

反编译后如下图:Constant pool就是常量池,#1、#2这些就是符号引用。

*methodB()调用methodA()对应的指令是9: invokevirtual #36 // Method methodA:()V。

Return Address(方法返回地址) 存放调用该方法的PC寄存器的值。也就是存放下一条要执行的指令的地址。一个方法退出有两种情况,一种是执行完成正常退出;另一种是执行过程中发生异常并且未捕获处理,造成异常退出。无论通过哪种方式退出,在方法退出后都将返回到该方法调用的位置。方法正常退出时调用方法的PC计数器作为返回地址,即调用该方法的指令的下一条指令地址。而通过异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

本质上来说,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回结果值压入调用方法栈帧的操作数栈中、设置PC计数器值等操作,让调用方法可以继续执行下去。而异常退出时是不会给调用方法返回任何值的。

附加信息 栈帧中还允许携带一些与Java虚拟机实现相关的一些附加信息,比如,支持程序调试所需要的信息等。

Native Method Stacks 本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只在于虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法服务。HotSpot将本地方法栈和虚拟机栈合二为一了。

Heap Java堆是虚拟机所管理的内存中最大的一块,也是线程共享的一块内存区域,在虚拟机启动时创建。当前主流的Java虚拟机都是按照可扩展来实现的,通过参数** - Xmx和-Xms **设置大小。Java heap的唯一目的就是为了存放对象实例。

Java堆是垃圾收集器管理的内存区域,从回收内存的角度来看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以在DJK8之前Java堆中又被逻辑划分为**新生代(New Gen)、老年代(Old Gen)、永久代(Perm Gen),**而在JDK8之后将是**新生代(New Gen)、老年代(Old Gen)、元空间(Metaspace)**。这些区域的划分仅仅是一部分垃圾收集器的共同特性或者说是设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。

根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机在实现的时候,处于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

如果从内存分配的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(thread local allocation buffer, TLAB),以提升对象分配时的效率。为了更快地分配内存,JVM将Eden空间划分为几个子区域,每个子区域专用于特定线程。这些专用区域中的每一个都简称为线程本地分配缓冲区或TLAB。分配新对象时,JVM将在专用于原始线程的TLAB中分配该对象。由于每个线程只能写入自己的TLAB,因此不需要同步。TLAB默认是启用的,可以使用**-XX:-UseTLAB**调整标志来禁用它。

通常在两种情况下使用TLAB:

  • 所分配的对象生命周期很短,可使用TLAB,避免在共享堆中分配增加后续的GC回收。
public class ThreadLocalAllocationBuff {

    private void execute(){
        System.out.println("execute");
    }

    public static void main(String[] args) {
        ThreadLocalAllocationBuff tlab = new ThreadLocalAllocationBuff();
        tlab.execute();
    }
}

tlab这个对象的生命周期很短,不会逃出main方法体之外,也就是可以随着方法的调用结束自动消亡,这种场景下能在TLAB中分配对象最佳。

  • 解决指针碰撞的场景

    在多线程时,线程A为对象a分配完内存,还没来得及修改指针。线程B就开始为对象b分配内存了,此时线程B仍然引用的是尚未被线程A修改的指针地址,该场景称为指针碰撞,此时的指针属于线程共享资源。在开启TLAB的情况下,线程在初始化时,会申请一块 size 固定的内存(通过**-XX:TLABSize**参数可以设置),仅为当前线程使用。每个线程都有了自己的独立的内存分配空间后,就不存在内存分配竞争的关系了,同时,分配的效率也提升了。当然了,TLAB的出现只是为了让每个线程有各自的内存分配指针,而这些线程所创建的对象仍然是可以被所有线程访问的。

    TLAB同样存在一些限制,首先就是空间大小限制,由于是在Eden区上操作,为了减少空间浪费TLAB一般都很小,如果给一个大对象分配,可能都放不小,例如100KB的TLAB无法分配200KB的大对象。-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小 默认是1%。通过**-XX:ResizeTLAB **可以开启JVM自动调整TLAB的大小,自动调整策略受应用程序线程数、分配率、Eden区大小三个因素影响。

1.当剩余空间 < 最大浪费空间时,该TLAB所属的线程会重新向Eden申请一个TLAB(旧的TLAB不作清理,会留在原地)。创建对象时发现还是不够空间,则此对象太大,直接去Eden区创建(即TLAB外的Eden区域)。

2.当剩余空间 > 最大浪费空间时,不重新申请TLAB,直接去Eden创建对象。

3.Eden区不够内存了,堆的Eden区开始GC。

最大浪费空间这个阈值可以使用TLABRefillWasteFraction来调整,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste。默认情况下,TLAB和refill_waste都会在运行时不断调整的,使系统的运行状态达到最优。

因为TLAB允许空间浪费,所以会存在很多不连续的空间(空间碎片),以后还需要人整理。在TLAB之外分配对象。由于分配是直接在Eden空间内进行的,因此称为慢分配。创建一个新的TLAB并将该对象分配到新的TLAB中。从技术上讲,JVM淘汰了旧的TLAB。

Method Area 方法区与Java堆一样,是线程共享的内存区域,它用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。HotSpot是通过永久代来实现方法区的,从JDK8以后永久代被元空间所替代,所以这部分数据就存储到本地内存中。

运行常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征就是具备动态性,在Java中并非预置入Class文件中常量池的内容才能进入方法区的运行时常量池,程序运行期间也可以将新的常量放入池中,这种特性被使用较多的便是**String.intern()**方法。

Direct Memory(直接内存) 直接内存并不是JVM运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OOM异常出现。

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

直接内存的大小可以通过**-XX:MaxDirectMemorySize**参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。

初探对象分配


1.类加载机制检查:JVM首先检查一个new指令的参数是否能在常量池中定位到一个符号引用,并且检查该符号引用代表的类是否已被加载、解析和初始化过

2.分配内存:把一块儿确定大小的内存从Java堆中划分出来

3.初始化零值:对象的实例字段不需要赋初始值也可以直接使用其默认零值,就是这里起的作用

4.设置对象头:存储对象自身的运行时数据,类型指针

5.执行<init>:为对象的字段赋值

JVM有两种堆内存分配的方式,分别是指针碰撞(Bump The Pointer)和空闲列表(Free List)。

  • 指针碰撞:假设Java堆中内存是绝对规整的,所有使用过的内存被放到一边,空闲的内存被放到另一边,中间放着一个指针作为分界点的指示器,所有分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。

  • 空闲列表:如果Java堆中的内存并不规整,已被使用的内存和空闲内存交错在一起,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,在分配内存时从列表中找出一块足够大的空间块分配给对象实例,并更新列表中的记录。

选择哪种分配方式是由Java堆是否规整决定的,而Java堆是否规整又是由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定的。因此在使用Serial、ParNew等带有压缩整理过程的收集器时,系统采用的是指针碰撞的分配方式,既简单又高效。而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存。

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对其填充(Padding)。

  • Header

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

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

  • Instance Data:对象真正存储的有效信息,即我们代码中的对象内容。

  • Padding:这部分不是必然存在的,也没有特殊含义,它仅仅是起着占位符的作用。

    创建对象后自然是为了使用对象,JVM通过reference来操作堆上的具体对象,主流的访问方式有两种,分别是通过句柄访问和直接使用指针访问:

  • 句柄访问:如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。

  • 指针访问:如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

这两种对象访问方式各有优势,使用句柄来访问最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改。使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销。HotSpot主要是通过直接指针进行对象访问的。

内存溢出


OutOfMemoryError

/**
* VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp
* @author zhangchi
*/
public class HeapOom {
   static class OomObject{};
   public static void main(String[] args) {
       List<OomObject> list = new ArrayList<OomObject>();
       while (true){
           list.add(new OomObject());
      }
  }
}

打印java.lang.OutOfMemoryError: java heap space

通过jprofiler分析dump文件内容,可以直观的看到内存对象状况。

Mac使用eclipse memory analyzer:

下载独立的MAT应用程序https://www.eclipse.org/mat/downloads.php。

解压后得到一个mat.app文件,运行时需要指定工作路径。

/Users/XXXXX/Documents/software/mat/mat.app/Contents/MacOS/MemoryAnalyzer -data ./workspace

StackOverflowError

由于HotSpot虚拟机并不区分虚拟机栈和本地方法栈,因此栈容量只能通过-Xss参数来设定,-Xoss参数是不起作用的。并且HotSpot不支持动态扩展栈区大小,所以除非是在创建线程申请内存时就因无法获取足够的内存而出现OutOfMemoryError异常,否则在线程运行过程中是不会因扩展内存导致溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError。

/**
 * -Xss152k
 * @author zhangchi
 */
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak(){
        stackLength ++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF sof = new JavaVMStackSOF();
        try {
            sof.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length: " + sof.stackLength);
            throw e;
        }
    }
}


方法区和运行时常量池溢出

JDK7开始逐步去永久代,JDK8中完全使用元空间代替永久代。通过下面测试代码比较下使用永久代和元空间来实现方法区,对程序有什么实际影响。

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。JDK6或更早的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和**-XX:MaxPermSize**限制永久代的大小,即可间接限制其中常量池的容量。

/**
 * VM Args : -XX:PermSize=2M -XX:MaxPermSize=2M
 * @author zhangchi
 */
public class MethodAreaOom {
    public static void main(String[] args) {

        Set<String> set = new HashSet<String>();
        short i = 0;
        while (true){
            set.add(String.valueOf(i++).intern());
        }
    }
}

以上代码分别在JDK6和JDK13两个版本下执行,JDK6下会发生OOM并且标注了是在PermGen space区域,而在JDK13下会一直执行,并且明确提示在1.8以后的版本中忽略了PermSize和MaxPermSize两个参数。

通过使用**-Xmx**限制堆大小为6M以后,再次执行可以看到异常发生在堆中,进一步可以证明运行时常量池已经被移植到Java heap中。

Direct Memory 本机直接内存的大小可以通过**-XX:MaxDirectMemorySize参数指定,如果不去指定,默认与-Xmx**设置的Java堆内存一致。

/**
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 * @author zhangchi
 */
public class DirectMemoryOom {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

我在反编译之后的JDK12下运行上面的代码,没有直接抛出OutOfMemoryError,而是程序直接被kill掉了。

Process finished with exit code 137 (interrupted by signal 9: SIGKILL)

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看出有什么明显的异常信息,如果发现内存溢出之后产生的dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存的原因了。