14、JVM实战:深入理解JVM内存区域

今天开始详细分析JVM内存区域:

一、深入理解运行时数据区,先看代码示例:

*

1、 JVM向操作系统申请内存:;

JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地 址分配给 JVM ,接下来 JVM 就进行内部分配。

2、 JVM获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小-Xms30m-Xmx30m-Xss1m;

-XX:MaxMetaspaceSize=30m

3、 类加载(类加载的细节后续会讲):;

这里主要是把 class 放入方法区、还有 class 中的静态变量和常量也要放入方法区

4、 执行方法及创建对象:;

启动main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 student 对象,对象引用 student 就存放在栈中。 后续代码中遇到 new 关键字,会再创建一个 student 对象,对象引用 student 就存放在栈中。

*

JVM在操作系统上启动,申请内存,先进行运行时数据区的初始化,然后把类加载到方法区,最后执行方法。 方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈。 同时在方法的执行过程中创建的对象一般情况下都是放在堆中,最后堆中的对象也是需要进行垃圾回收清理的。

二、从底层深入理解运行时数据区

1、 堆空间分代划分;

堆被划分为新生代和老年代( Tenured ),新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。 (先需要有概念,后续对象分配和垃圾回收会细讲这块)

*

三、GC 概念

GC-Garbage Collection 垃圾回收,在 JVM 中是自动化的垃圾回收机制,我们一般不用去关注,在 JVM 中 GC 的重要区域是堆空间。 我们也可以通过一些额外方式主动发起它,比如 System.gc(), 主动发起。 (项目中切记不要使用)

*

当我们通过 Java 运行以上代码时,JVM 的整个处理过程如下:

1、 JVM向操作系统申请内存,JVM第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间;

2、 JVM获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小;

3、 完成上一个步骤后,JVM首先会执行构造器,编译器会在.java文件被编译成.class文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,静态变量和常量放入方法区;

4、 执行方法启动main线程,执行main方法,开始执行第一行代码此时堆内存中会创建一个Teacher对象,对象引用student就存放在栈中执行其他方法时,具体的操作:栈帧执行对内存区域的影响栈帧执行对内存区域的影响;

*

四、深入辨析堆和栈

1、 功能;

以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量( int 、 short 、 long 、 byte 、 float 、 double 、 boolean 、 char 等)以 及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放; 而堆内存用来存储 Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中;

2、 线程独享还是共享;

栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。 堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。

3、 空间大小;

栈的内存要远远小于堆内存

五、虚拟机内存优化技术

1、 栈的优化技术——栈帧之间数据的共享;

在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的 JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠。(主要体现在方 法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。

*

使用JHSDB 工具查看栈空间一样可以看到。

public class JVMStack {

    public int work(int x) throws Exception{
        int z =(x+5)*10;//局部变量表有, 32位
        Thread.sleep(Integer.MAX_VALUE);
        return  z;
    }
    public static void main(String[] args)throws Exception {
        JVMStack jvmStack = new JVMStack();
        jvmStack.work(10);//10  放入main栈帧  10 ->操作数栈
    }
}

六、内存溢出

1、 栈溢出;

参数: -Xss1m , 具体默认值需要查看官网: https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#BABHDABI

*

HotSpot 版本中栈的大小是固定的,是不支持拓展的。

java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。

虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归 ( 循环来实现 ) 都有 存在的意义。递归代码简洁,非递归代码复杂但是速度较快。

OutOfMemoryError :不断建立线程,JVM 申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了)

同时要注意,栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。

2、 堆溢出;

内存溢出:申请内存空间 , 超出最大堆内存空间。

如果是内存溢出,则通过 调大 -Xms , -Xmx 参数。

如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么久应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间, 再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。

3、 方法区溢出;

1 运行时常量池溢出

2 方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了我们配置。

注意 Class 要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

1、 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例;

2、 加载该类的ClassLoader已经被回收;

3、 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;

*

cglib 是一个强大的,高性能,高质量的 Code 生成类库,它可以在运行期扩展 Java 类与实现 Java 接口。

CGLIB 包的底层是通过使用一个小而快的 字节码 处理框架 ASM ,来转换字节码并生成新的类。除了 CGLIB 包, 脚本语言 例如 Groovy 和 BeanShell , 也是使用 ASM 来生成 java 的字节码。当然不鼓励直接使用 ASM ,因为它要求你必须对 JVM 内部结构包括 class 文件的格式和指令集都很熟悉。

4、 本机直接内存溢出;

直接内存的容量可以通过 MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常;

由直接内存导致的内存溢出,一个比较明显的特征是在 HeapDump 文件中不会看见有什么明显的异常情况,如果发生了 OOM ,同时 Dump 文件很小,可 以考虑重点排查下直接内存方面的原因。

七、常量池

1、Class 常量池 ( 静态常量池 )

在class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table) ,用于存放编译期间生成的各种 字面 量和符号引用

2、字面量: 给基本类型变量赋值的方式就叫做字面量或者字面值。

比如:String a=“b” ,这里 “b” 就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。

符号引用 : 符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量, JAVA 在编译的时候一个每个 java 类都会被编译成一个 class 、文件,但在编译的时候虚拟机并不知道所引用类的地址( 实际地址 ) ,就用符号引用来代替,而在类的解析阶段(后续 JVM 类加载会具体讲到)就是为了把 这个符号引用转化成为真正的地址的阶段。

一个java 类(假设为 People 类)被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,因 此只能使用符号引用(org.simple.Tool )来代替。而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为 Tool 类的实际内存地址。

3、运行时常量池

运行时常量池( Runtime Constant Pool )是每一个类或接口的常量池( Constant_Pool )的运行时表示形式,它包括了若干种不同的常量:

从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这个是虚拟机规范中的描述,很生涩) 运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。 运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。 在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,所谓"Your father will always be your father"。变动的只是方法 区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。

4、字符串常量池

字符串常量池这个概念是最有争议的, King 老师翻阅了虚拟机规范等很多正式文档,发现没有这个概念的官方定义,所以与运行时常量池的关系不 去抬杠,我们从它的作用和 JVM 设计它用于解决什么问题的点来分析它。 以 JDK1.8 为例,字符串常量池是存放在堆中,并且与 java.lang.String 类有很大关系。设计这块内存区域的原因在于: String 对象作为 Java 语言中重 要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。 所以要彻底弄懂,我们的重心其实在于深入理解 String 。

八、String

String 类分析( JDK1.8

String 对象是对 char 数组进行了封装实现的对象,主要有 2 个成员变量: char 数组, hash 值。

*

String 对象的不可变性

了解了 String 对象的实现后,你有没有发现在实现代码中 String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。 我们知道类被 final 修饰代表该类不可继承,而 char[] 被 final+private 修饰,代表了 String 对象不可被更改。 Java 实现的这个特性叫作 String 对象的不 可变性,即 String 对象一旦创建成功,就不能再对它进行改变。

Java 这样做的好处在哪里呢? 第一, 保证 String 对象的安全性。假设 String 对象是可变的,那么 String 对象将可能被恶意修改。

第二,保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。

第三,可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 String str= “ abc ”;另一种是 字符串变量通过 new 形式的创建,如 String str = new String( “ abc ” ) 。

String 的创建方式及内存分配的方式

1 String str= abc ”;

当代码中使用这种方式创建字符串对象时, JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中 被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。(str 只是一个引用)

*

2 String str = new String( abc )

首先在编译类文件时, "abc" 常量字符串将会放入到常量结构中,在类加载时,“ abc" 将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc ” 字符串,在堆内存中创建一个 String 对象;最后, str 将引用 String 对象。

*

**

4 String str2= "ab" + "cd" + "ef" ;

编程过程中,字符串的拼接很常见。前面我讲过 String 对象是不可变的,如果我们使用 String 对象相加,拼接我们想要的字符串,是不是就会产生多个 对象呢?

分析代码可知:首先会生成 ab 对象,再生成 abcd 对象,最后生成 abcdef 对象,从理论上来说,这段代码是低效的。

编译器自动优化了这行代码, 编译后的代码,你会发现编译器自动优化了这行代码,如下

String str= "abcdef";

5、 大循环使用+;

*

intern

String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用。

*

1、 newSting()会在堆内存中创建一个a的String对象,king"将会在常量池中创建;

2、 在调用intern方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用;

3、 调用newSting()会在堆内存中创建一个b的String对象4、在调用intern方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用所以a和b引用的是同一个对象;

如果需要查看 String 的编译优化,需要使用到反编译工具,推荐 JD-GUI : http://java-decompiler.github.io/

这篇到此结束,下篇我们深入分析JVM的对象和引用相关,敬请期待!