JVM整体结构和内存模型
# JVM整体结构和内存模型
# 一、前言
Java语言是跨平台的,它从软件层面屏蔽不同操作系统在底层硬件与指令上的区别
# 二、JVM整体执行过程
我们先看一个代码示例:
public class Math {
public static final int initData = 666;
public static User user = new User();
public int compute() { //一个方法对应一块栈帧内存区域
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
当我们执行java com.ruanyou.Math
这样一个命令,整体过程大致如下:
1、首先会创建一个JVM虚拟机
2、随后通过类装载子系统加载类
方法区会存在类信息,堆存在一个类的Class对象,堆中存在实例信息,实例对象的头信息指向哪?
3、类加载完成后,JVM会执行Math类的main方法,在栈里面创建栈帧,创建对象以及给对象分配内存空间
4、满足一定条件后,进行垃圾回收
# 三、JVM运行时数据区
我们先看一个配置示例:
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar
-Xms:设置堆的初始可用大小,默认物理内存的1/64
-Xmx:设置堆的最大可用大小,默认物理内存的1/4
-Xmn:新生代大小
-XX: NewRatio:默认2。表示新生代占年老代的1/2,占整个堆内存的1/3。
-XX: SurvivorRatio:默认8。表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。
-XX:+UseAdaptiveSizePolicy:Eden与Survivor区默认8:1:1(会自动变化,默认开启)
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
-XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。
由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。
-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
-Xss:每个线程的栈大小
# 1、 堆
堆分为新生代和老年代,比例为1:2。新生代又分为Eden、SurvivorFrom和SurvivorTo区,比例为8:1:1
# 2、 方法区(元空间)
元空间并不在虚拟机中,而是使用本地内存。因此,元空间的大小仅受本地内存限制
Java8中的JVM元空间是不是方法区?
严格来说,不是。
- 首先,方法区是JVM规范的一个概念定义,并不是一个具体的实现,每一个JVM的实现都可以有各自的实现;
- 然后,在Java官方的HotSpot 虚拟机中,Java8版本以后,是用元空间来实现的方法区;在Java8之前的版本,则是用永久代实现的方法区;
- 也就是说,“元空间” 和 “方法区”,一个是HotSpot 的具体实现技术,一个是JVM规范的抽象定义;
所以,并不能说“JVM的元空间是方法区”,但是可以说在Java8以后的HotSpot 中“元空间用来实现了方法区”。
然后多说一句,这个元空间是使用本地内存(Native Memory)实现的,也就是说它的内存是不在虚拟机内的,所以可以理论上物理机器还有多个内存就可以分配,而不用再受限于JVM本身分配的内存了。
方法区有什么?
- 常量 这里的常量指final修饰的成员变量
- 静态变量
- 类信息
常量池
- class常量池
Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)。
我们一般可以通过javap命令生成更可读的JVM字节码指令文件:javap -v Math.class
红框标出的就是class常量池信息,常量池中主要存放两大类常量:字面量和符号引用。
字面量 由字母、数字等构成的字符串或者数值常量
字面量只可以右值出现,所谓右值是指等号右边的值,如:int a=1 这里的a为左值,1为右值。在这个例子中1就是字面量。
int a = 1; int b = 2; int c = "abcdefg"; int d = "abcdefg";
1
2
3
4
符号引用 主要包括了以下三类常量:类和接口的全限定名 、字段的名称和描述符 、方法的名称和描述符
上面的a,b就是字段名称,就是一种符号引用,还有Math类常量池里的
Lcom/tuling/jvm/Math
是类的全限定名,main
和compute
是方法名称,()
是一种UTF8格式的描述符,这些都是符号引用。
- 运行时常量池
这些常量池现在是静态信息,只有在运行时被装入到内存后,这些符号才有对应的内存地址信息,就变成运行时常量池。符号引用在程序加载或运行时会被转变为被加载到内存区域的代码的直接引用,也就是我们说的静态/动态链接。
静态链接是在类加载期间将符号引用替换为直接引用
当一个字节码 (opens new window)文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接是在程序运行期间将符号引用替换为直接引用
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
例如,compute()这个符号引用在运行时就会被转变为compute()方法具体代码在内存中的地址,主要通过对象头里的类型指针去转换直接引用。
# 3、 虚拟机栈
里面存放一个个栈帧,一个方法对应一块栈帧内存区域,栈帧结构如下:
局部变量表 方法内的局部变量,
示例:a=1,b=2,User user = new User();其中a,b,user都是局部变量,但是不考虑逃逸分析,user此处为指针,具体对象在堆中
操作数栈 是一个栈,存放操作数的临时空间
示例:a=1,b=2,其中1,2就是操作数
动态链接
方法出口
# 4、 本地方法栈
# 5、 程序计数器
存放下一条指令所在单元的地址
# 四、字符串常量池详解
# 1、字符串常量池的设计思想
字符串作为最基础的数据类型,如果大量频繁的创建,会极大程度地影响程序的性能。因此,JVM为了提高性能和减少内存开销,引入了字符串常量池,在实例化字符串常量的时候进行了一些优化
- 为字符串开辟一个字符串常量池,类似于缓存区
- 创建字符串常量时,首先会判断在字符串常量池中是否存在该字符串
- 若存在该字符串,则返回引用实例,若不存在,则实例化该字符串并放入常量池中
三种字符串操作(Jdk1.7 及以上版本)
- 直接赋值字符串 只会存在字符串常量池中
String s = "hello1"; // s指向常量池中的引用
因为有"hello1"这个字面量,创建对象s的时候,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象,
如果有,则直接返回该对象在常量池中的引用;
如果没有,则会在常量池中创建一个新对象,再返回引用。
- new String(); 字符串常量池和堆中都有这个对象
String s1 = new String("hello1"); // s1指向内存中的对象引用
因为有"hello1"这个字面量,所以会先检查字符串常量池中是否存在字符串"hello1",不存在,先在字符串常量池里创建一个字符串对象;
再去内存中创建一个字符串对象"hello1",存在的话,就直接去堆内存中创建一个字符串对象"hello1";
最后,将内存中的引用返回。
intern()
方法
String s1 = new String("hello1");
String s2 = s1.intern();
System.out.println(s1 == s2); //false
2
3
String中的intern方法是一个 native 的方法,当调用 intern方法时,如果字符串常量池已经包含一个等于此对象的字符串(用equals(oject)方法确定),则返回池中的字符串。否则,不再重新创建实例,而是直接指向堆上的实例(jdk1.6版本需要将 s1 复制到字符串常量池里)。
字符串常量池位置
Jdk1.6-: 有永久代,运行时常量池在永久代,运行时常量池包含字符串常量池
Jdk1.7:有永久代,字符串常量池从永久代里的运行时常量池分离到堆中
Jdk1.8+: 无永久代,运行时常量池在元空间,字符串常量池依然在堆中
用一个程序证明下字符串常量池在哪里:
/**
* jdk6:-Xms6M -Xmx6M -XX:PermSize=6M -XX:MaxPermSize=6M
* jdk8:-Xms6M -Xmx6M -XX:MetaspaceSize=6M -XX:MaxMetaspaceSize=6M
*/
public class RuntimeConstantPoolOOM{
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<String>();
for (int i = 0; i < 10000000; i++) {
String str = String.valueOf(i).intern();
list.add(str);
}
}
}
运行结果:
jdk7及以上:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
jdk6:Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 2、字符串常量池的设计原理
字符串常量池底层是hotspot的C++实现的,类似于一个 HashTable, 本质上保存的是字符串对象的引用。
看一道比较常见的面试题,下面的代码创建了多少个 String 对象?
String s1 = new String("he") + new String("llo");
String s2 = s1.intern();
System.out.println(s1 == s2);
// 在 JDK 1.6 下输出是 false,创建了 6 个对象
// 在 JDK 1.7 及以上的版本输出是 true,创建了 5 个对象
// 当然我们这里没有考虑GC,但这些对象确实存在或存在过
2
3
4
5
6
7
为什么输出会有这些变化呢?主要还是字符串池从永久代中脱离、移入堆区的原因, intern() 方法也相应发生了变化:
1、在 JDK 1.6
中,当调用 intern方法时,如果字符串常量池已经包含一个等于此对象的字符串(用equals(oject)方法确定),则返回池中的字符串;否则,虚拟机会重新在永久代上创建一个实例,将 StringTable 的一个表项指向这个新创建的实例。
2、在 JDK 1.7+
中,由于字符串常量池不在永久代了,为了更加方便地利用堆中的对象,intern() 方法做了一些修改。字符串存在时和 JDK 1.6一样,但是字符串不存在时,不再重新创建实例,而是直接指向堆上的实例。
由上面两个图,也不难理解为什么 JDK 1.6 字符串池溢出会抛出 OutOfMemoryError: PermGen space ,而在 JDK 1.7 及以上版本抛出 OutOfMemoryError: Java heap space 。
String常量池问题的几个例子
示例1:
String s0="hello1";
String s1="hello1";
String s2="hel" + "lo1";
System.out.println( s0==s1 ); //true
System.out.println( s0==s2 ); //true
2
3
4
5
分析:因为例子中的 s0和s1中的”hello1”都是字符串常量,它们在编译期就被确定了,所以s0==s1为true;而”hel”和”lo1”也都是字符串常量,当一个字 符串由多个字符串常量连接而成时,它自己肯定也是字符串常量,所以s2也同样在编译期就被优化为一个字符串常量"hello1",所以s2也是常量池中” hello1”的一个引用。所以我们得出s0==s1==s2;
示例2:
String s0="hello1";
String s1=new String("hello1");
String s2="hel" + new String("lo1");
System.out.println( s0==s1 ); // false
System.out.println( s0==s2 ); // false
System.out.println( s1==s2 ); // false
2
3
4
5
6
分析:用new String() 创建的字符串不是常量,不能在编译期就确定,所以new String() 创建的字符串不放入常量池中,它们有自己的地址空间。
s0还是常量池 中"hello1”的引用,s1因为无法在编译期确定,所以是运行时创建的新对象”hello1”的引用,s2因为有后半部分 new String(”lo1”)所以也无法在编译期确定,所以也是一个新创建对象”hello1”的引用;明白了这些也就知道为何得出此结果了。
示例3:
String a = "a1";
String b = "a" + 1;
System.out.println(a == b); // true
String a = "atrue";
String b = "a" + "true";
System.out.println(a == b); // true
String a = "a3.4";
String b = "a" + 3.4;
System.out.println(a == b); // true
2
3
4
5
6
7
8
9
10
11
分析:JVM对于字符串常量的"+"号连接,将在程序编译期,JVM就将常量字符串的"+"连接优化为连接后的值,拿"a" + 1来说,经编译器优化后在class中就已经是a1。在编译期其字符串常量的值就确定下来,故上面程序最终的结果都为true。
示例4:
String a = "ab";
String bb = "b";
String b = "a" + bb;
System.out.println(a == b); // false
2
3
4
5
分析:JVM对于字符串引用,由于在字符串的"+"连接中,有字符串引用存在,而引用的值在程序编译期是无法确定的,即"a" + bb无法被编译器优化,只有在程序运行期来动态分配并将连接后的新地址赋给b。所以上面程序的结果也就为false。
示例5:
String a = "ab";
final String bb = "b";
String b = "a" + bb;
System.out.println(a == b); // true
2
3
4
5
分析:和示例4中唯一不同的是bb字符串加了final修饰,对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。所以此时的"a" + bb和"a" + "b"效果是一样的。故上面程序的结果为true。
示例6:
String a = "ab";
final String bb = getBB();
String b = "a" + bb;
System.out.println(a == b); // false
private static String getBB()
{
return "b";
}
2
3
4
5
6
7
8
9
10
分析:JVM对于字符串引用bb,它的值在编译期无法确定,只有在程序运行期调用方法后,将方法的返回值和"a"来动态连接并分配地址为b,故上面 程序的结果为false。
最后再看一个例子:
//字符串常量池:"计算机"和"技术" 堆内存:str1引用的对象"计算机技术"
//堆内存中还有个StringBuilder的对象,但是会被gc回收,StringBuilder的toString方法会new String(),这个String才是真正返回的对象引用
String str2 = new StringBuilder("计算机").append("技术").toString(); //没有出现"计算机技术"字面量,所以不会在常量池里生成"计算机技术"对象
System.out.println(str2 == str2.intern()); //true
//"计算机技术" 在池中没有,但是在heap中存在,则intern时,会直接返回该heap中的引用
//字符串常量池:"ja"和"va" 堆内存:str1引用的对象"java"
//堆内存中还有个StringBuilder的对象,但是会被gc回收,StringBuilder的toString方法会new String(),这个String才是真正返回的对象引用
String str1 = new StringBuilder("ja").append("va").toString(); //没有出现"java"字面量,所以不会在常量池里生成"java"对象
System.out.println(str1 == str1.intern()); //false
//java是关键字,在JVM初始化的相关类里肯定早就放进字符串常量池了
String s1=new String("test");
System.out.println(s1==s1.intern()); //false
//"test"作为字面量,放入了池中,而new时s1指向的是heap中新生成的string对象,s1.intern()指向的是"test"字面量之前在池中生成的字符串对象
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3、为什么String类在Java中是不可变的?
String 设计成不可变,主要是从性能和安全两方面考虑
# 3.1 字符串常量池
字符串常量池是 Java 堆内存中一个特殊的存储区域,当创建一个 String 对象时,假如此字符串已经存在于常量池中,则不会创建新的对象,而是直接引用已经存在的对象。这样做能够减少 JVM 的内存开销,提高效率。
所以,如果字符串是可变的,那么字符串常量池就没有存在的意义了
# 3.2 hashcode缓存
因为字符串不可变,所以 String 缓存了 hashcode ,不需要重新计算。这就使得字符串很适合作为 HashMap 中的 key,效率大大提高
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** Cache the hash code for the string */
private int hash; // Default to 0
public int hashCode() {
int h = hash;//从缓存取
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 3.3 同步
不可变对象天生线程安全
多线程中,可变对象的值很可能被其他线程改变,造成不可预期的结果。而不可变的 String 可以自由在多个线程之间共享,不需要同步处理。
# 3.4 安全
String在很多Java类中被广泛用作参数,例如网络连接,文件打开等。假设String是可变的,某个方法以为正在连接到一个机器,实际并没有,这会导致严重的安全隐患。
# 4、Java中 final 关键字的作用?
final 表示不可改变,可以用来修饰类、方法和属性
final 修饰类
表示类不可以被继承。例如 String 类是 final 的
final 修饰方法
表示方法不可以被重写。例如在模版设计模式中,模版方法往往被设计成 final
final 修饰属性
表示属性不可以改变
这里不可改变的意思对基本类型来说是其值不可变,而对对象属性来说其引用不可再变