JVM 学习笔记(二)
继续了解 JVM 的内存结构。
JVM 内存结构(续)
JVM 内存结构之堆
此前学习的三个 JVM 内存结构:程序计数器、虚拟机栈、本地方法栈,它们的一个共同特点是都是线程私有的,即每个线程有自己的程序计数器、虚拟机栈和本地方法栈。而剩下的 JVM 内存结构,堆和方法区,则都是线程共享的,因此需要考虑线程的安全问题。
首先来看堆,堆有以下特点:
程序中我们通过 new 关键字创建出来的对象都是存储在堆中的
线程共享(上述)
堆中存在重要的垃圾回收机制
堆既然是一块内存,就有可能有满了的情况,即堆内存溢出。比如如果我们无限创建对象,就会出现 OutOfMemoryError
的错误,并提示 heap 空间满了,正是因为对象都是存储在堆中的。
我们可以通过 -Xmx
JVM 启动参数来指定 JVM 可分配的最大堆内存,当然如果不指定的话,默认值是和物理机内存有关的一个值。另外一个参数 -Xms
是指定 JVM 启动时堆的初始大小。
堆内存非常重要,Java 提供了一系列的诊断工具可以让我们来检查:
jps
可以让我们查看操作系统中有那些 Java 进程定位到进程编号之后,可以通过
jmap
来查看堆内存的占用情况。不过这个工具只能提供一个时刻的快照信息,并且在高版本的 Java 中似乎不可用了jconsole
是一个方便的图形界面工具,不仅可以查看堆内存,还有关于线程、CPU 占用率等丰富的功能,而且它提供的是可视化的实时监控功能在
jconsole
中我们还可以手动执行 GC
jvisualvm
是另一个好用的可视化工具,需要下载安装。利用它我们可以进行 heap dump,来查看堆内存中的细节情况。
JVM 内存结构之方法区
方法区英文叫 Method Area,JVM 规范中规定了这个区域:
被所有的线程共享
存储类、类加载器、运行时常量池等信息(略)
在 JVM 启动时创建
它在逻辑上是堆的一部分,但是具体存储在哪要看 JVM 的具体实现
我们可以通过下图来理解方法区的结构:
首先可以看到 Method Area 方法区存储着运行时常量池、类、类加载器等信息。但是我们也可以看到在 Java 1.6 和 1.8 中,它在虚拟机中的实现细节是不一样的:
1.6 中方法区存储在永久代 PermGen
1.8 中它存储在本地内存中名叫元空间的一个位置,并且常量池中的 StringTable 还是留在了堆中
虽然感性认识上不是那么直观,但是方法区的内存也会溢出,发生 OutOfMemoryError
的错误,一个验证演示代码如下:
如果我们强制让类加载很多字节码(方法区就会加载字节码),那么就会出现内存溢出的错误。当然,这个示例使用了一些用代码加载字节码的模拟,了解即可。
这个例子虽然独特,但并不意味着在平时的开发中,方法区内存溢出就离我们很远,我们使用的很多框架都使用了字节码技术,来动态的生成类,而这就有可能造成方法区内存溢出。比如 Spring 使用了 cglib 来在运行时生成代理对象,再比如 MyBatis 的 Mapper 类也是动态生成的。
在早期版本方法区位于永久代的时候,其位于 JVM 内部,内存限制比较大,而且垃圾回收可能也不及时,造成内存溢出的概率大。1.8 之后转移到本地内存的元空间中存储时,物理机能提供的内存更大,而且垃圾回收也是由本地内存自行管理的,因此方法区内存不太容易溢出。
方法区中的运行时常量池
方法区中有一个重要的组成部分,运行时常量池。
首先理解一下常量池,我们可以从编译产生的字节码文件中看到它。当使用 javap -v <Name>.class
反编译字节码之后,我们可以看到字节码中有一个 constant pool 的部分,并且在类的方法调用中,都会通过编号从这个常量池中寻找类、参数、方法等信息。因此,常量池就是字节码中的一张表,jvm 指令都会从这张表中找到要执行的类、方法和参数。
进一步就是运行时常量池,当类被加载的时候,它的常量池信息就会被放入运行时常量池,并且里面的符号的编号地址就会变为真实的内存地址。
运行时常量池中的字符串常量池
运行时常量池中的一个重要部分是 StringTable,也就是我们常说的字符串常量池。
我们通过一个「代码中使用字符串,底层发生了什么」的案例来理解字符串常量池
上面这段代码中String s1 = "a"
对应的字节码就是 0: ldc #2
和 2: astore-1
,意思就是首先找到 "a" 这个字符串,然后存储到一个变量中。这两条 JVM 指令都是在编译好的 .class 字节码文件中。
等到程序开始运行的时候,常量池中的信息被加载到运行时常量池中,但是此时字符串 "a" 还只是个符号,还没有产生字符串对象。直到程序运行到 11 行,需要使用字符串 "a" 时,虚拟机区 StringTable 里看,没有这个字符串(程序刚开始)于是就创建了一个。对于下面的字符串常量也是一样的,会先去 StringTable 里找有需要的字符串,有就返回,没有就创建一个放入并返回。
在这个大体的理解下,有一些进一步的衍生情况,比如当「字符串拼接」的情况发生时:
直接说结论吧,从反编译的字节码中,我们看到 String s4 = s1 + s2;
这个语句对应的执行是使用 StringBuilder 来追加 "a" 和 "b",然后返回 toString() 的字符串,从源码中可以看到实际上就是 new 了一个字符串。因此 s4 的结果和字符串常量池里的 "ab" 不是同一个对象,因为它是 new 出来的。
但是如果我们使用的是常量字符串进行拼接操作:
从反编译的字节码中我们可以看到 Stirng s5 = "a" + "b";
是去寻找 "ab" 这个字符串。为什么呢?因为我们使用的是常量字符串,编译器针对这种情况会有一定的优化,能够推断出常量拼接的结果是不变的,因此在编译期就能确定结果是 "ab" 了。而上一个例子中,使用变量拼接,就会在运行时动态完成拼接。
关于字符串是在代码执行到对应行才创建的这一点,我们可以通过 IDEA 的 debug 工具来验证,可以使用 Memory 这个选项卡看到堆内存中对象的情况:
可以看到,每执行一行代码 String 类型的对象才增加一个。所以代码中的字符串常量并不是程序开始运行时候就都创建好了的。
另外,我们可以使用字符串类的 intern()
方法,手动地将某个字符串对象放入字符串常量池,它的效果是:
如果已经有这个字符串了,就不会放入
如果没有,就放入这个对象并返回这个对象
因此在下面这段代码中:
第一行出现的 "a" 和 "b" 两个字符串常量被放到了字符串常量池中
但是 new String("a") 和 new String("b") 都是利用常量新创建出来的对象
它们的拼接是通过 StringBuilder 来实现的,并且,虽然结果是一个 "ab",但是对象只存在于堆中,没有在串池中
使用 s.intern() 可以手动把这个字符串放入,并且返回的对象就是放进去那个对象
因此 s2 和 s 再去和字符串常量 "ab" 进行比对的时候,三者就都是串池中的那个对象了
但是如果把代码稍作更改:
最开始声明了一句 String x = "ab"
。那么这时候,第一行语句执行完成的时候,串池中就已经有了字符串常量 "ab",因此 s.intern() 就因为已经存在而不会被放入,而 s2 也是串池中那个已经存在的对象了。因此 s2 == x,而 s != x。
不过,关于 intern()
方法的底层行为,在 Java 1.6 之前和之后也是不一样的,上面的逻辑「如果字符串常量池中没有就把调用 intern() 的字符串放进去」,这是 1.8 之后的逻辑,而在 1.6 中,其行为是把字符串复制一份然后放入串池中,再把串池中的这个新对象返回,也就是说调用 intern 的字符串和返回的字符串不是一个。
直接上一段代码案例吧,如果能把这里的输出分析明白了,也就算明白了:
// 1.6 的结果是什么?1.8 的结果是什么?
public static void main(String[] args) {
String s = new String("a") + new String("b");
String s2 = s.intern();
String x = "ab";
System.out.println(s2 == x); // 1.6 true 1.8 true
System.out.println(s == x); // 1.6 false 1.8 true
}
// 1.6 的结果是什么?1.8 的结果是什么?
public static void main(String[] args) {
String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == x); // 1.6 true 1.8 true
System.out.println(s == x); // 1.6 false 1.8 false
}
先分析 1.6 的行为:
第一个函数
串池中一开始先有了 "a" 和 "b"
然后字符串拼接在堆上得到一个新字符串 "ab"
尝试 s.intern() 放入的时候,串池里没有,于是复制一个新的字符串放入再返回,所以 s2 != s
x 是串池里的常量,因此 x == s2
于是输出 true,false
第二个函数
串池中先有了 "ab", "a", "b"
然后字符串拼接在堆上得到一个新字符串 "ab"
尝试 s.intern() 放入的时候,已经有了,因此 s2 直接得到串池里的字符串
于是 s2 == x, s != x
结果是 true false
再分析 1.8 的行为,就清晰很多了
第一个函数:因为 s.intern() 放入的就是堆上的那个新变量,于是 s == s2 == x
第二个函数:1.6 和 1.8 的行为没有区别,串池中有的情况下 s2 是现存串池对象和 x 相等,和 s 不等
再看一道代码分析题:
s3 != s4 因为 s3 是编译期分析好的,串池里的 "ab",而 s4 是堆上的新对象
s3 == s5 是因为都是串池常量
s6 == s3 是因为,s6 得到了串池的返回
x1 != x2 因为 x2 是堆上的新对象,x1 是串池中的变量,x2.intern() 没有放成功
如果先 x2.intern() 再 String x1 = "cd" 那么就是 true,因为 x1 得到的是放入的对象
但是如果是 1.6 放入的不是对象而是复制的拷贝,因此还是 false
总之,StringTable 的规则需要多看几遍,理解清楚底层行为,就能分析清楚了。
下面了解一下字符串常量池的具体位置,看这张图:
在 1.6 中,字符串常量池位于永久代。这里的一个问题是永久代只有 Full GC 的时候才会被垃圾回收,因此如果程序中有大量的字符串,不能及时回收就会占用内存
在 1.7 中,字符串常量池放入了堆中,minor GC 的时候就会被垃圾回收
可以通过一个代码案例来证明,同时延伸学习一些细节:
这里通过 intern()
方法不断地往字符串常量池里放字符串,于是就会让其内存不足,由于字符串加入了 list 中,因此不会被垃圾回收。我们观察报错的信息即可。
在 1.6 中,如果我们通过
-XX:MaxPermSize=10m
设置了永久代最大内存的限制,那么就会看到 PermGen 内存溢出在 1.8 中,我们通过
-Xmx10m
设置最大堆内存,但是一开始的报错信息是GC Overhead Limit Exceeds
,这是因为 JVM 的一条规则如果用了很多时间来进行 GC 但是只能收回有限的 heap 内存,就会爆出这个错误,为的是防止程序运行很长时间但没有收益。
我们可以使用
-XX:-UseGCOverheadLimit
来关闭这个功能,其中-
代表着关闭之后我们就能观察到 heap space 的报错信息了
接下来了解一下字符串常量池的垃圾回收机制。首先要明确字符串常量池是有垃圾回收的,比如我们可以通过如下思路来证明:
-Xmx10m
来限制堆内存的最大限制-XX:+PrintStringTableStatistics
来打印出字符串常量池的统计信息-XX:+PrintGCDetails -verbose:gc
来打印出发生垃圾回收的情况于是当我们不断地往字符串常量池里添加字符串的时候,通过常量池中的数量和是否发生垃圾回收,就能看出实际上有没有用到的字符串是被回收了的
关于 StringTable 我们也可以进行一些性能调优,实际上就是调优底层的哈希表,避免出现过多的哈希碰撞。
可以通过改变
-XX:StringTableSize
来改变哈希表桶的大小,让数据能够分散的更均匀,通过程序计时就能看出性能的区别再有是如果程序中重复字符串很多的话,可以使用 intern() 方法将字符串放入常量池,节省内存的使用。我们可以通过辅助工具比如 VisualVM 的抽样器来看到内存中各个类的数目。
JVM 内存结构之直接内存
直接内存并不存在于之前结构图中,但是它也是很重要的。它有以下特点:
常见于 NIO 操作中,用于数据缓冲区
分配回收成本高(因为是操作系统的资源),但是读写性能高
不受 JVM 内存回收的管理(需要手动释放空间)
我们通过 Java 文件读取的过程细节来总结直接内存:
传统上 Java 中的文件读写可以如下图示意:
Java 不具备文件的读写能力,因此需要调用操作系统的方法(进入内核态),操作系统会有系统缓冲区,但是 Java 不能直接使用,因此 Java 自己的缓冲区还需要把系统缓冲区的数据进行一次拷贝,因此效率就比较低
但是如果使用直接内存,通过
ByteBuffer.allocateDirect()
来分配一块直接内存的话(NIO 操作),操作系统就会分配出一片内存,Java 也可以直接访问,因此就提升了效率
直接内存也是会溢出的,也就是说如果我们不断地调用 ByteBuffer.allocateDirect()
,也会导致 OutOfMemoryError(Direct Memory)。
直接内存的回收不是通过 JVM 进行的,也就是说 System.gc() 是不会回收这块分配出来的内存的。但是实际上,当 ByteBuffer 对象被回收之后,从操作系统的监控中可以看到 Direct Memory 被回收了,那么底层是如何实现的呢?
实际上,ByteBuffer 类就是通过一个底层的 Unsafe 类来进行分配和释放内存的。在其实现类 DirectByteBuffer
的构造器中,我们看到了 UNSAFE.allocateMemory()
的调用,然后同时使用了 Cleaner(虚引用)对象来检测 ByteBuffer 对象的存活。这个虚对象的第二个构造参数 Deallocator 是一个 Runnable 对象,在 ByteBuffer 对象被垃圾回收的时候就会在 ReferenceHandler 线程中调用 Cleaner 的 clean 方法,里面调用的就是其 run 方法,里面就是通过 Unsafe 的 freeMemory()
方法手动释放内存的。因此,Cleaner 虚引用对象相当于一个监测的回掉函数机制。
因此,关于直接内存的垃圾回收实际上是当 ByteBuffer 对象被垃圾回收的时候,被动触发的。于是乎,如果我们使用了 -XX:+DisableExplicitGC
这个参数来禁用掉显示 GC 的话,那么 ByteBuffer 的回收就必须等到 GC 自动触发才能进行,如果内存够用的话这个过程可能会比较慢,于是就影响了直接内存的回收。当然,如果需要的话,我们也可以手动使用 Unsafe 类来进行回收。
类