内容导读
- JVM内存模型
- Class文件常量池, 运行时常量池, 字符串常量池
一. JVM内存模型
JVM内存模型主要就是JVM的运行时数据区. 一共分为五块区域: 堆, 方法区, 栈,本地方法栈,程序计数器.
其中, 堆和方法区是线程共享的内存区域, 栈, 本地方法栈和程序计数器是线程私有的内存区域.
1. 线程栈
线程栈的最小单位是栈帧, 而栈帧由局部变量表, 操作数栈, 动态链接以及方法出口组成. 线程栈是线程私有的, 每执行一个方法都会创建一个栈帧并入栈,方法结束后出栈.线程栈没有垃圾的问题, 因为栈帧随着方法开始而创建, 方法结束而销毁.
但是线程栈有OOM的异常. 从深度上讲, 有StackOverflowException, 从广度上讲, 有OutOfMemoryException.下文会给出两个异常的代码示例. 可以通过-Xss设置栈的大小
// 设置栈的大小为1m
-Xss=1m
- 局部变量表
存放基本类型的变量和对象引用 - 操作数栈
用于计算, 先是从局部变量表读取变量, 然后进行计算, 计算完后, 会将结果放入局部变量表. - 动态链接
简单的说, 就是保存一个引用, 该引用指向的地址是该栈帧所对应的方法, 在运行时常量池中的方法地址. 目的是支持放在调用过程的动态链接. 用于实现多态.
public static void main(String[] args) {
Father father = new Son();
father.say();
}
上面的方法在栈内会创建两个栈帧, main方法和say()方法. 而say()方法实际指向的是子类Son中的say方法. 这个过程就是通过动态链接确定的.
- 方法出口
用于记录方法被调用的位置. 比如上面的代码, 在say()方法执行结束后, 需要返回到main()方法中调用的地址.
2. 程序计数器
简单来说, 程序计算寄存器就是用来记录代码所在的行数.CPU执行指令的载体是线程,由于CPU采用时间片轮限制, 在同一时刻, 只会执行一个线程的指令. 这就需要CPU现在线程间来回切换. 因此需要记录每个线程执行的指令所在的行号.
为了确保每次切换都能找到正确的位置, 每个线程都需要有自己的程序计数寄存器. 不同线程之间不能相互应用. 因此, 程序计数寄存器是线程私有的.
同时, 程序计数寄存器也是唯一没有OOM的内存区域
3. 本地方法栈
用于执行native的方法. 现在用的很少, 一般也用不到
4. 堆
堆是垃圾收集的主要区域, 也是OOM的重灾区. 堆是java内存区域中最大的一块内存区域, 线程共享. 堆用于存在对象实例和数组. JDK1.7以后, 字符串常量池也放入堆中.
可以通过-Xms设置初始大小, -Xmx设置最大值, -Xmn设置最小值.
-Xms : 设置初始大小
-Xmn : 设置最小值
-Xmx : 设置最大值
通常情况下, 对象实例在堆上分配, 但是也有特殊情况, 当开启逃逸分析和标量替换后, 一些对象实例可以直接在栈上分配.
堆被细分为年轻代和老年代, 年轻代又分为Eden区, Survivor0区和Survivor1区.
年轻代和老年代的比例是1:2, 可以通过-XX:NewRatio=2来设置比例.Eden区和两个Survivor区的比例是8:1:1, 可以通过XX:SurvivorRatio=2来设置比例,
-XX:+UseAdaptiveSizePolicy该参数开启时, JVM可以自动调整Eden区和Survivor区的比例
5. 方法区
主要存放常量, 类元信息(Klass), 静态变量以及JIT编译后的代码,
JDK1.8之前, 方法区也叫做永久代. 可以通过通过-XX:PermSize设置方法区的大小, -XX:MaxPermSize设置最大值
JDK1.8以后, 取消了方法区, 用元空间代替.可以通过XX:MetaspaceSize设置大小, -XX:MaxMetaspaceSize设置最大值. 同时元空间有一个自动扩容的机制, 即当元空间内存不够时, 会自动进行扩容.
1.8之前
-XX:PermSize
-XX:MaxPermSize
1.8之后
-XX:MetaspaceSize
-XX:MaxMetaspaceSize
二. 以一个示例将内存模型串联起来
有这么一段代码
public class Test{
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
int d = add(c);
}
private static int add(int num) {
return 2 * num;
}
}
当执行Test.class的main方法时, 会在栈上分配一个main方法的栈帧.
当执行到第一行代码时, 局部变量表会在第二个位置存入a=1(第一个位置默认存放this), 程序计数器会记录当前第一行代码的地址;
执行第二行代码时, 局部变量表会在第三个位置存入b=1, 程序计数器会记录当前第二行代码的地址;
执行第三行代码时, 操作数据栈会从局部变量表中读取a和b的值, 然后进行运算, 得到c=3, 并放入局部变量表的第四个位置, 程序计数器会记录当前第三行代码的地址;
在执行第四行代码时, 会分配add方法的栈帧, add方法的局部变量表存入num = 3, 然后操作数栈读取num的值并进行运算, 同时放入局部变量表.然后返回操作数据栈栈顶的数据.方法出口记录add方法在main方法中的位置.
总结下来, 如图所示
三. Class文件常量池, 运行时常量池, 字符串常量池的区别
字面量
字面量就是由字母, 数字等构成的字符串或者数值常量, 如字符串常量, 声明为final的常量, 包括静态变量, 实例变量和局部变量. 字面量只能出现在等式右边.如
int a = 1;
int b = 2;
a为左值, 1为右值, 1就是字面量
符号引用
符号引用是编译原理中的概念, 主要包括以下三类常量
- 类的全限定名
- 字段名和描述
- 方法签名和描述
上面的a, b是字段名称, 就是符号引用
直接引用
- 指针或者引用
Class文件常量池
Class文件在编译时, 会将字面量和符号引用放入Class文件常量池.
运行时常量池
当Class文件被加载到内存时, 类元信息, 常量, 静态变量会放到方法区. 这些符号引用才有内存地址, 而Class文件常量池一旦被加载到内存, 就变成了运行时常量池. 对应的符号引用在程序加载或运行时, 会转换成在内存区域的直接引用.
字符串常量池
JDK1.7以后, 字符串常量池放到了堆中.
字符串常量池的本质是jvm在堆中维护了一个关于字符串的缓存.方便字符串可以共用.节省内存空间.
- new String("abc") 和 "abc"的区别
public class Test { public static void main(String[] args) { String a = "abc"; String b = new String("abc"); System.out.println(a == b); // false } }
String a = "abc"; 会在字符串常量池中放入一个字符串"abc"; 而String b = new String("abc"); 先检查字符串常量池中有没有"abc", 没有的话, 会在字符串常量池中创建"abc", 然后在堆内创建一个String对象"abc"的实例, 将String对象"abc"实例赋值给引用b. 此时, a指向的是字符串常量池中的"abc", 而b指向的是堆中的String对象"abc"的实例.
字符串常量池底层是通过C++实现的, 类似于HashTable的结构, 本质上是保存了字符串对象的引用.
- intern()
String a = new String("he") + new String("llo");
String b = a.intern();
System.out.println(a == b);
以上代码很简单, 一共就两行, 但是在不同的JDK版本, 有着完全不同的结果.
在JDK1.6之前, 输出false, 一共创建了六个对象性. 首先是创建堆中的he, llo, hello对象和字符串常量池中的he和llo对象, 在调用intern()方法时, 先去字符串常量池中判断是否有hello对象, 没有则在字符串常量池中创建一个hello对象.
a和b分别指向的是不同的对象, 所以返回false
而在JDK1.7以后, 输出true, 一共创建了五个对象.首先是在堆中创建he , llo, hello三个对象, 然后字面量创建两个对象.在调用intern方法时,字符串常量池中没有时, 可以直接使用对中的对象.