此篇博客主要以笔记的形式,记录笔者在B站《深入理解JVM》课程中学到的知识点。课程地址:《黑马程序员JVM完整教程,全网超高评价,全程干货不拖沓》
上图为JVM内存结构概况图,本篇博客我们将来探究JVM中的方法区(图片来自课程截图)
1.方法区的定义
这里笔者引用oracle官网对jdk1.8
对方法区的定义(下面是来自百度翻译的译文,需要查看原文的读者可以进入此链接:The Structure of the Java Virtual Machine)。
Java虚拟机有一个在所有Java虚拟机线程之间共享的方法区域。方法区域类似于常规语言编译代码的存储区域,或类似于操作系统进程中的“文本”段。它存储每个类的信息,例如 运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法 。
方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但简单的实现可能选择不进行垃圾收集或压缩
。本规范不强制要求方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小,也可以根据计算要求进行扩展,如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的。
Java虚拟机实现可为程序员或用户提供对方法区域初始大小的控制,以及在大小不同的方法区域的情况下,对最大和最小方法区域大小的控制。
以下异常情况与方法区域相关:
如果方法区域中的内存无法满足分配请求,Java虚拟机将抛出OutOfMemoryError
异常。
2.Java6和8方法区的区别
- Java6中的方法区称为
永久代
,在逻辑上属于堆的一部分。 - Java8中的方法区称为
元空间
,并且不在虚拟机中,而是在本地内存中
上面两张图片分别表示Java6与Java8中,JVM方法区的结构示意图(两张图片均来自课程截图)。
3.运行时常量池
在探究运行时常量池之前,我们先来了解一下常量池。
常量池就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息等。
以下面的hello world
为例,在命令行中执行javap -v Demo2.class
命令,对Demo2.class文件进行反编译(下面是反编译后的内容)
反编译后出来的信息大概分为三种:类的基本信息
、常量池
、类方法定义
(如:构造方法、main方法等),其中Constant poll
模块就是常量池。
接下来我们来按照反编译出来的信息对虚拟机执行main
方法的步骤走一遍
// 反编译后常量池的信息
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/bosen/www/Demo3
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/bosen/www/Demo3;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Demo3.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/bosen/www/Demo3
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
// 反编译后main方法的信息
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
- 进入到
main
方法后,执行的第一条代码命令是getstatic #2
,此时需要到常量池查找#2
。 - 来到常量池,发现
#2 = Fieldref #21.#22
,则继续往下走寻找#21
和#22
,可以陆续找到java/lang/System
对象和java/io/PrintStream
对象,即对应的是代码中的Systeam.out
。 - 执行完上一条指令后执行
ldc #3
,再次到常量池中查找#3
,#3 = String #23
-->#23 = Utf8 hello world
,此时找到了需要打印的字符串hello world
。 - 得到字符串后紧接着是打印的指令
invokevirtual #4
-->#4 = Methodref #24.#25
最终调用方法println
实现字符串的打印功能,即System.out.println("hello world");
。
懂得了常量池基本的工作原理后,理解运行时常量池将会变得容易很多。常量池是.class
文件中的,当该类被加载时,它的常量池信息就会放入到运行时常量池中,并把里面的符号地址变为真实的地址~!
为什么需要将常量池信息放入运行时常量池?
- 因为在实际业务逻辑中,并不只是打印一段“hello world”这么简单的需求,往往需要多个类共同工作。但常量池记录的只是一个类的常量信息,只有当需要合作的类常量信息都放在一起时,才可以保证类和类之间的合作~!
4.串池StringTable
特性
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接原理是编译器优化
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池
接下来我们将用下面几个具体的代码示例来说明上述的特性
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
String x1 = new String("c") + new String ("d");
String x2 = "cd";
x1.intern();
String x3 = new String("e") + new String ("f");
x3.intern();
String x4 = "ef";
System.out.println("示例一:" + (s3 == s4)); // false
System.out.println("示例二:" + (s3 == s5)); // true
System.out.println("示例三:" + (s3 == s6)); // true
System.out.println("示例四:" + (x1 == x2)); // false
System.out.println("示例五:" + (x3 == x4)); // true
示例一
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
System.out.println("示例一:" + (s3 == s4)); // false
执行上述代码后,堆中内存的变化如下:
因为s1
、s2
、s3
中的字符串是直接用双引号定义的,因此这三个对象都会直接存在串池中。但是s4
是通过s1+s2
的方式对两个String
类型的对象进行拼接的,JVM在会使用StringBuilder
对象来完成拼接操作并重新实例化一个已经拼接好的字符串对象,而这种通过实例化即new
出来的对象并不会存入串池中。
因此,s3
和s4
的值虽然都是ab
,但是两个指向的并不是同一内存地址,s3 != s4
。
示例二
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s5 = "a" + "b";
System.out.println("示例二:" + (s3 == s5)); // true
执行上述代码后,堆中内存的变化如下:
会发现,在串池中只会存在一个ab
字符串,并不会出现两个。这是因为在执行String s3 = "ab"
时,虚拟机会先到串池中查找是否存在该字符串,如果没有则加入串池,有则返回该字符串在串池的地址。而在执行String s5 = "a" + "b"
时,虚拟机会对其进行拼接后再与串池中的字符串进行对比,而拼接后的结果为ab
,此时在串池中已经存在了字符串ab
,所以返回该字符串在串池中的地址信息。
因此,s3
和s5
指向的都是同一内存地址,s3 == s4
。
示例三
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s6 = s4.intern();
System.out.println("示例三:" + (s3 == s6)); // true
执行上述代码后,堆中内存的变化如下:
通过示例一,我们知道,s4
中的字符串是不存储在串池中的,但我们可以通过调用intern
方法将在堆中的存在但串池中不存在的字符串对象存储到串池中;如果串池中已经存在该字符串对象,则不会将该对象存储到串池中。(需要注意的是: 在jdk1.8
中是将堆中的字符串移动到串池,而1.6
则是复制堆中的对象到串池)
综上所述,s4
中的字符串对象会执行intern
方法,但由于串池中已经存在字符串ab
,所以s6
对应的内存地址应该是原本存储在串池中ab
的内存地址,因此s6
的内存地址s3
是一致的,即s3 == s6
。
示例四
String x1 = new String("c") + new String ("d");
String x2 = "cd";
x1.intern();
System.out.println("示例四:" + (x1 == x2)); // false
执行上述代码后,堆中内存的变化如下:
赋值x1
时,字符串c、d
都是通过new
创建的,并且通过+
号拼接,因此这段代码将会在堆中存入三个字符串对象(c, d, cd),而x2
则是通过双引号定义字符串将直接存入到串池中的。接着调用x1
的intern
方法,由于此时串池中已经存在字符串cd
,所以x1
对应的字符串对象,即堆中的cd
并不会存储到串池中。
因此,x1与x2指向的内存地址是不一致的,即x1 != x2
。
示例五
String x3 = new String("e") + new String ("f");
x3.intern();
String x4 = "ef";
System.out.println("示例五:" + (x3 == x4)); // true
执行上述代码后,堆中内存的变化如下:
示例五与示例四相似。首先在堆中会产生三个字符串对象(e、f、ef
),由于x3
调用了intern
方法,因此ef将会被存储到串池中,并且x3
指向的内存地址也将会在串池中的(再次强调: 在jdk1.8
中是将堆中的字符串移动到串池,而1.6
则是复制堆中的对象到串池),所以,执行String x4 = "ef"
时,串池将不会再次创建新的字符串对象,因为此时串池中已经存在ef
。
因此,x3与x4指向的内存地址是一致的,即x3 == x4
。
5.一些小疑惑
在学习过程中,笔者对于方法区的一些问题产生了疑惑,特在此记录一下:
常量池和串池的区别: 常量池中存储的只是一些常量符号,还没真正生成对应的字符串对象,需要运行加载操作时候,才会真正生成对象。而串池存储的是实在的字符串对象。
为什么1.6与1.8的串池在虚拟机中的位置发生了改变: 在1.6中串池是设置在方法区中的,当到了1.8却设置在了堆中。其主要的原因是因为串池中的对象使用频率高,因而需要进行gc的频率也高。所以设计者从程序运行效率上考虑,将串池设置在了堆中。