JVM从跨平台到跨专业Ⅰ– 内存结构与对象探秘

导读:本篇文章讲解 JVM从跨平台到跨专业Ⅰ– 内存结构与对象探秘,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

也许你感觉自己的努力总是徒劳无功,但不必怀疑,你每天都离顶点更进一步。今天的你离顶点还遥遥无期。但你通过今天的努力,积蓄了明天勇攀高峰的力量。加油!

前言

什么是JVM

Java Virtual Machine ,Java 程序的运行环境(Java 二进制字节码的运行环境)。

好处:

  • 一次编译,处处执行
  • 自动的内存管理,垃圾回收机制
  • 数组下标越界检查
  • 多态

比较
JVM、JRE、JDK 的关系如下图所示:
在这里插入图片描述

常见的JVM

JVM其实是一套规范,只要遵从这套规范,你甚至可以自己开发一套JVM的实现

在这里插入图片描述

后面的内容均是基于HotSpot

JVM组成部分

在这里插入图片描述

我们的一个类从Java的源代码编译成为Java的二进制字节码以后,必须经过ClassLoader(类加载器)才能被加载到JVM里去运行。类被放在方法区部分,类将来创建的实例对象是放在堆的部分,而堆里面的对象调用方法的时候又会用到虚拟机栈、程序计数器、本地方法栈。

方法执行时,每行代码是由执行引擎中的解释器逐行进行执行。方法里的热点代码,也就是被频繁调用的代码,会被即时编译器优化执行。而垃圾回收负责对堆里不再被引用的对象进行回收。而有一些Java不方便实现的功能,比如说要与底层操作系统打交道,就需要使用本地方法接口。

JVM的内存结构

程序计数器

Program Counter Register 程序计数器(寄存器)

作用:是记录下一条 jvm 指令的执行地址行号。

在物理上实现程序计数器是使用寄存器来实现的。寄存器是整个CPU里读取速度最快的一个单元。

在这里插入图片描述
Java源代码被编译成为二进制字节码之后就成为了一个个的JVM指令。解释器从第一行开始解释,解释成机器码然后交给CPU处理,然后解释器就在程序计数器那里拿到下一条jvm指令的执行地址,然后循环往复。

特点

  • 线程私有
    • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
    • 程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
  • (唯一的一个区)不会存在内存溢出

虚拟机栈

经常有人把Java内存区域笼统地划分为堆内存 (Heap) 和栈内存 (Stack) ,这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。不过这种划分方式的流行也间接说明了程序员最关注的、与对象内存分配关系最密切的区域是“堆”和“栈”两块。其中“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分
在这里插入图片描述

在这里插入图片描述

  • 每个线程运行所需要的内存空间,被称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次调用方法时所占用的内存
  • 当一个方法中又调用另外一个方法的时候,就会出现多个栈帧相继压栈的情况,每当执行完一个方法,该方法就会出栈。
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的方法

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型 (boolean、byte.char、short、int、float、long、double) 、对象引用 (reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置) 和returnAddress类型 (指向了一条字节码指令的地)。

这些数据类型在局部变量表中的存储空间以局部变量槽 (Slot) 来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

请大家注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(警如按照1个变量槽占用32个比特、64个比特,或者更多) 来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

操作数栈

然后我们说说什么是操作数栈:

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。(操作数栈的实现是一个数组,当数组被创建出来的时候,它的长度已经确定,只不过操作数栈不能用索引来获取数据,它是一个栈结构,只有出栈和入栈操作)
  • 每一个操作数栈都会拥有一个明确的栈帧深度用来存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,为max_stack的值。
    • 如下图,javap 命令反编译后,stack=2 是操作数栈的最大栈深度,locals=3 是局部变量的长度。和局部变量表一样在编译期间就可以确定长度。
      在这里插入图片描述
  • 栈中的任何一个元素都是可以任意的java数据类型。32bit的类型占用一个slot。64bit的类型占用两个slot。
  • 如果被调用的方法带有返回值的化,其返回值将会被压入当前栈帧的操作数栈中,并更新pc寄存器中下一条需要执行的字节码指令

然后我们来看一个完整的例子:

  1. 编写代码
    在这里插入图片描述

  2. 反编译后的字节码指令是:
    在这里插入图片描述

  3. 刚执行这个方法,虚拟机栈就会创建栈帧。此时pc寄存器的值是0,局部变量表和操作数栈都是空的,但它们的长度已经确定。
    在这里插入图片描述
    在这里插入图片描述

  4. 然后执行引擎根据pc寄存器的值去执行操作指令:bipush ,将15 存储到操作数栈中。
    在这里插入图片描述

  5. 执行引擎根据pc寄存器的值去执行操作指令istore_1,操作数栈的15 出栈,存入到局部变量表索引为1的slot中(因为索引为0的slot中存放的是this的引用
    在这里插入图片描述

  6. 执行引擎根据pc寄存器的值去执行操作指令bipush, 将8存放到操作数栈中。
    在这里插入图片描述

  7. 执行引擎根据pc寄存器的值去执行操作指令istore_2,将操作数栈中的8出栈,存放到局部变量表索引为2的slot中

  8. 执行引擎根据pc寄存器的值去执行操作指令iload_1,将局部变量表中索引为1的数值入栈到操作数栈中
    在这里插入图片描述

  9. 执行引擎根据pc寄存器的值去执行操作指令iload_2,将局部变量表中索引为2的数值入栈到操作数栈中。
    在这里插入图片描述

  10. 执行引擎根据pc寄存器的值去执行操作指令iadd,执行引擎执行指令,通过解释器解析成机器指令,调用cpu,将8和15依次出栈,将13入栈到操作数栈中。
    在这里插入图片描述

  11. 执行引擎根据pc寄存器的值去执行操作指令istore_3,将操作数栈中的23出栈,存储到局部变量表所以为3的slot中。
    在这里插入图片描述

  12. 执行引擎根据pc寄存器的值去执行操作指令return,栈帧出栈。

代码演示:

public class Main {
	public static void main(String[] args) {
		method1();
	}

	private static void method1() {
		method2(1, 2);
	}

	private static int method2(int a, int b) {
		int c = a + b;
		return c;
	}
}

在这里插入图片描述

左边的Frames表示的就是虚拟机栈中的栈帧,右边的Variables代表着栈帧里分配的内存。而活动栈帧就表示栈顶部那个正在执行的方法。

问题辨析

  • 垃圾回收是否涉及栈内存?
    • 不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
  • 栈内存的分配越大越好吗?
    • 不是。我们知道栈代表一个线程所需要的内存空间,而物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
  • 方法内的局部变量是否是线程安全的?
    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全的
    • 如果是局部变量引用了对象,并逃离了方法的访问,那就要考虑线程安全问题。

代码示例:
在这里插入图片描述

上述代码在多个线程执行是不会出现线程安全的问题的;
在这里插入图片描述
但是如果我们将x改为静态变量,那么就会出现线程安全的问题:
在这里插入图片描述
再来看一个例子:
在这里插入图片描述
其中

  • m1线程安全,其局部变量在方法作用范围之内并未逃离
  • m2线程不安全,其参数不在方法的作用范围之内
  • m3线程不安全,其返回值为对象的引用,逃离了方法的作用范围

栈内存溢出

HotSpot虚拟机的栈容量是不可以动态扩展的,以前的Classic虚拟机倒是可以

Java.lang.stackOverflowError 栈内存溢出

发生原因:

  • 栈帧过多导致:例如无限递归

    • 在这里插入图片描述
  • 栈帧过大导致(不容易出现)

    • 在这里插入图片描述

另外我们还可以通过-Xss命令来指定栈内存大小;
在这里插入图片描述

另外我们还要注意有时候造成无限递归的可能不是我们,可能是我们引用的第三方库造成的,我们可以看看下面这个例子:

/**
 * json 数据转换
 */
public class Demo1_19 {

    public static void main(String[] args) throws JsonProcessingException {
        Dept d = new Dept();
        d.setName("Market");

        Emp e1 = new Emp();
        e1.setName("zhang");
        e1.setDept(d);

        Emp e2 = new Emp();
        e2.setName("li");
        e2.setDept(d);

        d.setEmps(Arrays.asList(e1, e2));

        // { name: 'Market', emps: [{ name:'zhang', dept:{ name:'', emps: [ {}]} },] }
        ObjectMapper mapper = new ObjectMapper();
        System.out.println(mapper.writeValueAsString(d));
    }
}

class Emp {
    private String name;
    @JsonIgnore
    private Dept dept;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Dept getDept() {
        return dept;
    }

    public void setDept(Dept dept) {
        this.dept = dept;
    }
}
class Dept {
    private String name;
    private List<Emp> emps;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Emp> getEmps() {
        return emps;
    }

    public void setEmps(List<Emp> emps) {
        this.emps = emps;
    }
}

最后我们会发现因为Emp与Dept对象的相互引用,而造成在json转换的时候造成无限递归
在这里插入图片描述

线程运行诊断

案例一:CPU占用过多

解决方法:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程

  • top 命令,查看是哪个进程占用 CPU 过高
    在这里插入图片描述

  • ps H -eo pid, tid(线程id), %cpu | grep 刚才通过 top 查到的进程号 通过 ps 命令进一步查看是哪个线程占用 CPU 过高
    在这里插入图片描述

  • jstack 进程 id 通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。

    • 这里的nid对应着Linux中tid的十六进制转换
      在这里插入图片描述

本地方法栈

也就是给本地方法的运行提供内存空间。

本地方法:指那些不是由Java语言编写的代码。因为Java代码有一定的限制,他有时候不能直接与我们的操作系统底层打交道,这个时候就需要与C或者C++语言编写的本地方法来实现相应的需求。

一些带有native关键字的方法就是需要JAVA去调用本地的C或者C++方法,例如我们Object中的clone方法、hashCode方法、notify方法、notifyAll方法等、wait方法。

在这里插入图片描述

Heap

定义

通过new关键字创建的对象都会被放在堆内存

特点

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制

堆内存溢出

java.lang.OutofMemoryError :java heap space. 堆内存溢出

可以使用-Xmx指令来指定堆内存大小。例如-Xmx8m就是指定堆内存的大小为8M。
在这里插入图片描述

堆内存诊断

  • jps 工具:查看当前系统中有哪些 java 进程
    在这里插入图片描述

  • jmap 工具:查看堆内存占用情况 jmap - heap 进程id
    在这里插入图片描述

  • jconsole 工具:图形界面的,多功能的监测工具,可以连续监测
    在这里插入图片描述

  • jvisualvm 工具:相比于前几个工具,它具有堆快照的功能(heap dump),可以看到堆里每个实例的情况。
    在这里插入图片描述

方法区

  • 方法区是所有Java虚拟机线程的共享区
  • 这块区域里存储了跟类的结构相关的信息,例如:类的成员变量、方法数据、成员方法和构造器的代码部分、运行时常量池
  • 方法区在虚拟机启动的时候被创建
  • 方法区在逻辑上是堆的一部分,但是各个厂商对JVM的实现可能不一样,不一定就会把堆中的一部分拿来做方法区。
  • 方法区可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!
  • 方法区也存在内存溢出

我们可以来看看HotSpot虚拟机在1.6、1.8版本中的内存结构中方法区的结构。

在这里插入图片描述

在1.6版本中HotSpot采用了永久代作为方法区的实现。我们可以看到这个永久代里存储了类的信息、类加载器、运行时常量池。

在这里插入图片描述
在1.8版本中永久代实现被废弃,采用了元空间来实现方法区。我们可以发现它不再占用堆内存,也就是说已经不由JVM来管理内存结构。并且将串池(String Table)从方法区拿了出来,放在了堆中。

方法区内存溢出

  • 1.8 之前会导致永久代内存溢出java.lang.OutOfMemoryError: PermGen space

    • 使用 -XX:MaxPermSize=8m 指定永久代内存大小
      在这里插入图片描述
  • 1.8 之后会导致元空间内存溢出java.lang.OutOfMemoryError: Metaspace

    • 使用 -XX:MaxMetaspaceSize=8m 指定元空间大小
    • 基本上很少发生元空间的内存溢出,因为其使用的是操作系统的内存,较为充裕。
      在这里插入图片描述

在实际的开发环境中这种情况也是有可能发生的。例如我们经常使用的spring、mybatis框架都使用了cglib(动态代理)技术:

  • spring用cglib来生成一些代理类,是Spring中的AOP核心
  • mabatis用cglib来生成mapper接口

这就导致了在运行期间可能会生成很多类,从而触发方法区内存溢出。

运行时常量池

首先我们先明白什么是常量池,我们将一下代码编译成.class字节码文件:

public class Test {

    public static void main(String[] args) {
        System.out.println("Hello World!");
    }

}

字节码包含:

  • 类的基本信息
  • 常量池
  • 类方法定义
  • 包含了虚拟机的指令

我们首先使用javac命令得到字节码文件,然后我们再使用如下命令将字节码文件进行反编译:

javap -v .class文件路径

然后能在控制台看到反编译以后类的信息了:

  • 类的基本信息
    在这里插入图片描述
  • 常量池
    在这里插入图片描述
    在这里插入图片描述
  • 虚拟机中执行编译的方法(框内的是真正编译执行的内容,#号的内容需要在常量池中查找)
    在这里插入图片描述
    • 每条指令都会对应常量池表中一个地址,常量池表中的地址可能对应着一个类名、方法名、参数类型等信息。
    • 我们仔细看可以发现常量池中等号后面就是数据类型,且所有的常量均为符号,最后都是通过UTF-8的方式解码成为机器码

运行时常量池

  • 常量池
    • 就是一张表(如上图中的constant pool),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
  • 运行时常量池
    • 常量池是.class文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。也就是把符号引用替换为直接引用。

StringTable(串池)与常量池的区别

StringTable(串池)用来放字符串对象且里面的元素不重复。

其实严格的来说这里说的不准确,串池里面放的并不是对象本身而是对象的引用,想要了解更多可以参考下面的文章:
class常量池、字符串常量池和运行时常量池的区别

特征

  • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder
  • 字符串常量拼接的原理是编译器优化
  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中

我们来看看下面的代码加深理解:

public class StringTableStudy {
	public static void main(String[] args) {
		String a = "a"; 
		String b = "b";
		String ab = "ab";
	}
}

我们将代码编译之后再进行反编译:

0: ldc           #2                  // String a
2: astore_1
3: ldc           #3                  // String b
5: astore_2
6: ldc           #4                  // String ab
8: astore_3
9: return

这里的ldc、astore是jvm可以识别的字节码指令集,如果想在这个方面了解更多,可以参考下面俩篇文章:
深入理解JVM(二十)字节码指令集与解析举例
字节码指令

在推演内存中的变化过程的时候,这个地方完全不用考虑常量池,常量池在编译的时候很早就创建好了,在运行这些字节码指令的时候他们早就存在了不会发生改变,此时只需要考虑与串池之间的互动。

常量池中的信息,都会被加载到运行时常量池中,但这时a、b、ab仅是常量池中的符号(也就是符号引用),还没有成为java字符串(直接引用)。

  • 当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容)

  • 当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,并放入串池中

  • 当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中

最终StringTable [“a”, “b”, “ab”]

注意:
字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。

接下来我们看两种情况:

情况一:使用拼接字符串变量对象创建字符串

public class StringTableStudy {
	public static void main(String[] args) {
		String a = "a";
		String b = "b";
		String ab = "ab";
		//拼接字符串对象来创建新的字符串
		String ab2 = a+b; 
	}
}

反编译后的结果

	 Code:
      stack=2, locals=5, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
         9: new           #5                  // class java/lang/StringBuilder
        12: dup
        13: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
        16: aload_1
        17: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        20: aload_2
        21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: astore        4
        29: return

通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()

最后的toString方法的返回值是一个新的字符串:
在这里插入图片描述
但字符串的值和拼接的字符串一致。不过这是两个不同的字符串,因为一个存在于串池之中,一个存在于堆内存之中:

String ab = "ab";
String ab2 = a+b;
//结果为false,因为ab是存在于串池之中,ab2是由StringBuffer的toString方法所返回的一个对象,存在于堆内存之中
System.out.println(ab == ab2);

情况二: 使用拼接字符串常量对象的方法创建字符串

public class StringTableStudy {
	public static void main(String[] args) {
		String a = "a";
		String b = "b";
		String ab = "ab";
		String ab2 = a+b;
		//使用拼接字符串的方法创建字符串
		String ab3 = "a" + "b";
	}
}

反编译后的结果:

 	  Code:
      stack=2, locals=6, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
         9: new           #5                  // class java/lang/StringBuilder
        12: dup
        13: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
        16: aload_1
        17: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        20: aload_2
        21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: astore        4
        //ab3初始化时直接从串池中获取字符串
        29: ldc           #4                  // String ab
        31: astore        5
        33: return
  • 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了“ab”,所以ab3直接从串池中获取值,所以进行的操作和 ab = “ab” 一致。
  • 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建

intern方法

1.8版本
调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

实际上intern方法是将字符串对象放入到常量池中当作一个符号,不过使用的时候还是会通过判断放进串池,结果是一样的。

  • 如果串池中没有该字符串对象,则放入成功(这是一个移动过程而不是复制粘贴)
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

我们看两个例子:

public class Main {
	public static void main(String[] args) {
		//"a" "b" 被放入串池中,str则存在于堆内存之中
		String str = new String("a") + new String("b");
		//调用str的intern方法,这时串池中没有"ab",则会将该字符串对象放入到串池中,此时堆内存与串池中的"ab"是同一个对象
		String st2 = str.intern();
		//给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回
		String str3 = "ab";
		//因为堆内存与串池中的"ab"是同一个对象,所以以下两条语句打印的都为true
		System.out.println(str == st2);
		System.out.println(str == str3);
	}
}

new String("a") + new String("b")我们前面提到过,这种属于使用StringBuilder动态拼接,是不会放入到串池中去的,而是会在堆中。

public class Main {
	public static void main(String[] args) {
        //此处创建字符串对象"ab",因为串池中还没有"ab",所以将其放入串池中
		String str3 = "ab";
        //"a" "b" 被放入串池中,str则存在于堆内存之中
		String str = new String("a") + new String("b");
        //此时因为在创建str3时,"ab"已存在与串池中,所以放入失败,但是会返回串池中的"ab"
		String str2 = str.intern();
        //false
		System.out.println(str == str2);
        //false
		System.out.println(str == str3);
        //true
		System.out.println(str2 == str3);
	}
}

1.6版本

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象

在这里插入图片描述
第一个返回true,第二个返回false。

有了前面的基础之后我们再来看一道面试题:
在这里插入图片描述

第一问,在1.8的环境下:

  • s1在串池里
  • s2在串池里
  • s3在串池里
  • s4使用StringBuilder动态拼接,在堆中
  • s5在串池里,并且跟s3是一个对象
  • s6使用intern方法,因为ab在串池之中已经存在,所以他会直接引用串池中的ab对象,也就是说他和s3、s5是一个对象。

所以第一问的三个结果是:fasle、true、true

第二问,在1.8的环境下:

  • x2为动态拼接在堆中,同时c、d进入串池
  • x1在串池里
  • x2使用intern方法,但是其返回值并没有进行接收,所以x2还是在堆中

故第二问的结果为false。当我们调换位置:
在这里插入图片描述

  • x2仍然在堆中
  • 当x2使用intern方法,那么串池中会添加cd,且指向堆中的x2
  • 因为cd已经存在于串池中,所以x1引用的就是x2的对象

答案为true,而如果此时使用的是1.6的环境:

  • x2在堆中
  • x2调用intern方法,将cd复制一份放到串池中
  • x1引用串池中的cd对象

所以答案为false

StringTable 垃圾回收

/**
 * 演示 StringTable 垃圾回收
 * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
 */
public class Code_05_StringTableTest {

    public static void main(String[] args) {
        int i = 0;
        try {
            for(int j = 0; j < 10000; j++) { // j = 100, j = 10000
                String.valueOf(j).intern();
                i++;
            }
        }catch (Exception e) {
            e.printStackTrace();
        }finally {
            System.out.println(i);
        }
    }

}
  • -Xmx10m 指定堆内存大小
  • -XX:+PrintStringTableStatistics 打印字符串常量池信息
  • -XX:+PrintGCDetails
  • -verbose:gc 打印 gc 的次数,耗费时间等信息

也就是说StringTable在内存紧张时,会发生垃圾回收,其中的元素并非一直会存在。

StringTable性能调优

  • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间

    -XX:StringTableSize=xxxx
    在这里插入图片描述

  • 考虑是否需要将字符串对象入池

    可以通过intern方法减少重复入池
    在这里插入图片描述

直接内存

  • 属于操作系统,常见于NIO操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受JVM内存回收管理

我们先来看看一个文件的读写流程;

在这里插入图片描述
在不使用直接内存的时候,会把要读取的文件从磁盘读到系统内存的缓存区,这个地方是不能运行java代码的,所以要继续把他读取到java堆内存中的缓存区。显然这个过程经过了两次拷贝比较耗费时间。

在这里插入图片描述
而如果使用了直接内存,操作系统会直接把文件从磁盘中读到直接内存中,直接内存是操作系统和Java代码都可以访问的一块区域,无需将代码从系统内存复制到Java堆内存,从而提高了效率,这样就省去了多次读取复制的时间,所以更加高效快捷。

直接内存的释放原理

我们前面说过直接内存不受jvm的管理,那么他什么时候被释放呢?

它是通过unsafe.freeMemory来手动释放

包括直接内存分配,也是通过Unsafe对象的allocateMemory方法申请的。一句话说,就是直接内存与Unsafe对象联系紧密

我们通过以下语句申请直接内存:

//通过ByteBuffer申请1M的直接内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);

我们来看看这个allocateDirect方法:

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

我们再继续深入看看这个DirectByteBuffer方法:

DirectByteBuffer(int cap) {   // package-private
   
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        base = unsafe.allocateMemory(size); //申请内存
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象
    att = null;
}

Cleaner在java中我们叫做虚引用类型,它的特点是:当他所关联的对象被回收的时候,就会触发clean方法,在这里我们可以发现他关联的是this,也就是DirectByteBuffer的对象(这个对象是受我们jvm管理的)。

我们来看看Cleaner中的clean方法:

public void clean() {
       if (remove(this)) {
           try {
               this.thunk.run(); //调用run方法
           } catch (final Throwable var2) {
               AccessController.doPrivileged(new PrivilegedAction<Void>() {
                   public Void run() {
                       if (System.err != null) {
                           (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                       }

                       System.exit(1);
                       return null;
                   }
               });
           }

我们前面可以看到Cleaner的create方法里有一个Deallocator对象,这个对象是一个回调任务对象,因为它实现了Runnable接口,而且它的run方法里我们看到了unsafe.freeMemory方法,然后clean又会调用这个run方法,这就完成了直接内存的回收。

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address); //释放直接内存中占用的内存
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

HotSpot中的对象探秘

对象创建

在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已。虚拟机中,对象的创建过程(仅限于普通Java对象,不包括数组和Class对象等)如下:

  1. 类加载检查: 当JVM遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

  2. 为新生对象分配内存: 在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。分配方式有两种:“指针碰撞”和“空闲列表”

    • 指针碰撞: 若Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
      在这里插入图片描述

    • 空闲列表: 若Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,JVM就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
      在这里插入图片描述

    • 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。强调“理论上”是因为在CMS的实现里面,为了能在多数情况下分配得更快,设计了一个叫做Linear Allocation Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式类分配。

    • 为新生对象分配内存时除要考虑如何划分可用空间之外,还要考虑线程安全性。场景:对象创建在JVM中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决方案有两种:

      • 对分配内存空间的动作进行同步处理——实际上JVM是采用CAS配上失败重试的方式保证更新操作的原子性
      • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
  3. 初始化零值: 内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

  4. 设置对象头信息JVM在初始化零值后还要对对象进行必要的设置例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

  5. 构造方法: 从JVM的视角来看,上述工作完成之后,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的< init >()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行< init >()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)
  1. 对象头: HotSpot虚拟机对象的对象头部分包括两类信息。

    • 第一类是用于存储对象自身的运行时数据如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表。
      在这里插入图片描述
    • 对象头的另外一部分是类型指针即对象指向它的类型元数据的指针,JVM通过这个指针来确定该对象是哪个类的实例。并不适合所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
  2. 实例数据实例数据部分是对象真正存储的有效信息,即在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

  3. 对齐填充对象的第三部分是对其填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

附:对象头结构示意图

以 32 位虚拟机为例

普通对象

|--------------------------------------------------------------|
|                    Object Header (64 bits)                   |
|------------------------------------|-------------------------|
|       Mark Word (32 bits)          |   Klass Word (32 bits)  |
|------------------------------------|-------------------------|
  • Klass Word指向当前实例的类对象(class),反映对象的类信息

数组对象

|---------------------------------------------------------------------------------|
|                             Object Header (96 bits)                             |
|--------------------------------|-----------------------|------------------------|
|        Mark Word(32bits)       |   Klass Word(32bits)  |  array length(32bits)  |
|--------------------------------|-----------------------|------------------------|

其中 Mark Word 结构为

|-------------------------------------------------------|--------------------|
|                  Mark Word (32 bits)                  |        State       |
|-------------------------------------------------------|--------------------|
|    hashcode:25  | age:4 |   biased_lock:0   |   01    |       Normal       |
|-------------------------------------------------------|--------------------|
|thread:23|epoch:2| age:4 |   biased_lock:1   |   01    |       Biased       |
|-------------------------------------------------------|--------------------|
|          ptr_to_lock_record:30              |   00    | Lightweight Locked |
|-------------------------------------------------------|--------------------|
|          ptr_to_heavyweight_monitor:30      |   10    | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
|                                             |   11    |    Marked for GC   |
|-------------------------------------------------------|--------------------|

64 位虚拟机 Mark Word

|--------------------------------------------------------------------|--------------------|
|                          Mark Word (64 bits)                       |        State       |
|--------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 |  01   |        Normal      |
|--------------------------------------------------------------------|--------------------|
| thread:54 |   epoch:2   | unused:1 | age:4 | biased_lock:1 |  01   |        Biased      |
|--------------------------------------------------------------------|--------------------|
|                    ptr_to_lock_record:62                   |  00   | Lightweight Locked |
|--------------------------------------------------------------------|--------------------|
|                 ptr_to_heavyweight_monitor:62              |  10   | Heavyweight Locked |
|--------------------------------------------------------------------|--------------------|
|                                                            |  11   |    Marked for GC   |
|--------------------------------------------------------------------|--------------------|

对象的访问定位

创建对象自然是为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种

  1. 使用句柄: 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如下图。
    在这里插入图片描述

  2. 直接指针: 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,其结构如下图。
    在这里插入图片描述

这两种对象访问方式各有优势:

  • 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改
  • 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁, 因此这类开销积少成多也是一项极为可观的执行成本。
  • 就HotSpot虚拟机而言,它主要使用直接指针的方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发),但从整个软件开发的范围来看,在各种语言、框架中使用句柄访问的情况也十分常见。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/121964.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!