从代码角度学习JVM(结合内存+类加载)
JVM构成基础知识
最基础的知识,JVM有以下几部分构成,分别是Java 堆,方法区,java虚拟机,native虚拟机,计数器。
Java层的方法都会在java虚拟机中运行,native即SO的方法在native中运行。
每个线程都会新起一个栈,栈中会单独针对该线程存在一个计数器,每个方法会在栈中新起一个栈帧。计数器的作用是为了记录当前线程内,运行到了哪一步,防止多线程中,CPU去执行了其他线程,又回来继续执行当前线程时候,可以知道从哪一步继续开始。
每当执行到一个方法时候,都会在栈中入一个栈帧,栈帧中包括局部变量表,操作数栈,返回数据地址,以及指向运行时常量池的引用。局部变量表用于存放方法的入参以及方法内部声明的变量。对于普通方法,局部变量表第一位也就是下标为0的槽位,存放的是当前对象的引用。操作数栈用于临时存放变量,辅助字节码执行,操作数栈的深度和局部变量表的深度在方法编译时候就已经确定下来。
关于Java 堆和方法区(元空间)
Java堆主要用于存放对象实例和数组,所有的对象都在这里分配内存,Java 堆是GC主要扫描回收的区域。
- JDK 1.7
Java 堆分为新生代,老年代,其中新生代又分为伊甸园,from survivor和to survivor。空间大小比例是8:1:1。
新生成的对象会直接在伊甸园分配内存,如果空间不够,会到老年代中分配。每次gc都会回收无用对象,每次gc时候(伊甸园满的时候会触发一次小规模gc),伊甸园中没被回收的对象会转移到from survivor中,from survivor中的对象存活时间满足一定周期会转移到to survivor中。survivor满了后,会触发一次full gc,将survivor中对象转移到老年代中。
方法区主要用于存储加载的类信息,静态变量和常量,字节码等。1.8之前,有时候说的java永久代就是方法区
- JDk 1.8
1.8之后,方法区不存在,由元空间替代,元空间存在于设备本地分配内存,不再在虚拟机中分配内存,所以元空间大小由本机内存所限制。
类信息,字节码,静态常量,常量都存在元空间中。
好,这里就引入了第一个疑惑:静态常量既然存在元空间中,那么它什么时候被回收呢?
静态常量和静态变量的生命周期
先理解一个概念:类对象。
- 什么是类对象: 每个类文件被加载时候,都会在元空间创建一个对应的class对象,也就是类对象,主要包含这个类文件的信息,包括类名,父类名,实现接口,方法等信息。 从代码层面可以理解为,我们通过String.class拿到的对象就是String这个类的类对象。
对于静态变量和静态常量,它不属于任何一个实例,只属于类对象。(代码层面理解就是,我们使用静态变量时候都是直接类名.xxx来使用)所以静态常量和静态变量只有在类对象被卸载时候才会被回收。
字符串常量池又很特殊,字符串常量池存在元空间中,所以如果没有任何引用指向它们,会随着堆的GC而被回收。
静态字符串常量存在于类文件的常量池中,不直接占用运行内存,个人理解不会被回收。具体可以参考下文的 【Java执行程序的两个阶段】
静态基本类型常量,会直接在加载阶段,放到字节码中,故相当于是存在元空间的类元数据中。
下个问题,类对象什么时候会被卸载呢?
类对象被卸载时机
需要满足以下3个条件,类才会被卸载:
- Java 堆中已经没有这个类的任何实例
- 加载这个类的classLoader已经被回收
- 这个类的类对象没有在任何地方被引用
想要理解这3条,我们接下来看下类的加载机制,才好理解,所以接下来看下JVM加载机制
JVM加载
JVM加载大步骤分几步:加载->连接->初始化->使用->卸载 其中连接这一步又分为3步:验证->准备->解析
当类存在父类时候,JVM加载子类时的先后顺序: 父类加载->子类加载->父类连接->子类连接->父类初始化->子类初始化
- 加载:就是从对应路径加载类文件转为类对象到元空间中
- 验证:对于加载好的数据,需要验证其类的正确性,是否符合规范,是否有非法的方法名或字段。验证字节码,验证符号引用,比如看下是否可以根据符号引用查找到其对象(这一步具体是在解析过程中进行的)。
- 准备:该阶段主要用于处理静态变量,包括静态常量。对于非静态常量,赋值默认值,对于基本类型的静态常量,赋值为默认值,比如int的都是0,对于静态常量,基本类型静态常量会直接赋值其真实数值,因为这个值会在编译阶段直接写入到字节码中。对于非基本类型静态常量,会赋值为null。注意:字符串静态常量,会在元空间存在字符串常量池,所以准备阶段,会直接将其进行赋值。
- 解析:该阶段是对类对象中的常量池中的符号引用转为直接引用的过程。该阶段如果发现符号引用的对象没有被加载,那么会触发其加载过程。
- 初始化:初始化阶段其实就是执行类的方法,按顺序执行static属性和代码块量。这里针对的也是静态变量和静态代码块,普通属性和普通代码块不会被执行。
需要注意的是,只要用到了类里的内容,类就会加载,但是类加载了不代表就一定会进行初始化,初始化只会在需要的时候进行。
- 可以触发一个类加载的时机
- new对象时候
- 访问其静态成员时候
- 发射调用时候
- 执行main方法时候,该方法所在类会被加载
Java执行程序的两个阶段
想到一个问题,对于下方代码:
public void aa() {
Object s = A.a;
}
public static class A {
public static String aaa = "i am aaaa";
public static final String a = "i am AA";
public static final InitLock i = new InitLock("");
static {
System.out.println("i am A");
}
}
执行方法aa()时候,我们直接调用了A.a,此时其实是不会打印字符串"i am A"的,这是为啥呢?
因为Java执行程序过程中有两个阶段:
- 编译阶段 - 将文件转为字节码
- JVN加载阶段 - 为程序分配内存。
-
编译阶段
这个时候java程序会把我们编写的Java源代码,java结尾的文件编译成字节码,也就是class结尾的文件,这个过程中,对于常量字符串,包括静态常量字符串,会直接保存到类文件的常量池中(这里说的常量池任然在文件中,还没到内存),对于基本类型常量,会直接写入到字节码中。对于其他静态常量对象,给其赋予符号引用。
-
JVM加载阶段
这个过程主要是将数据分配到内存中,方便程序的运行。
回到上方那个问题。
原因:在文件的常量池中可以直接找到静态字符串常量的值,所以当我们引用其静态常量字符串时候,并不会触发类的加载。但是如果是个非字符串或者非基本类型静态常量,因为常量池存储的是符号引用,所以就需要触发类的加载了,以便找到其直接引用。同理,如果是子类直接引用父类中的静态常量字符串也不会触发类加载。
类构造器初始化时机
会发现,类的加载,连接和初始化三步主要做的就是加载类文件,为静态变量分配内存。那么普通变量和方法什么时候执行以及分配内存呢?-
使用
对于普通变量的内存分配和初始化是在方法里执行,也就是new对象时候,触发了类构造器时候会执行方法。此时已经分配了对象实例,所以这些数据最终是和对象实例一起存到堆上的。
总结: 静态变量都是存在元空间中,非静态变量存在实例中,实例存在堆中。
说了这么多,JVM加载过程倒是随着一个个问题,理得差不多了。接下来继续回到JVM内存模型中。对于成员 变量,我们知道了是存在堆中,那么对于方法极其局部变量,是怎么分配内存的呢?接下来从栈帧中的局部变量表开始分析。
局部变量表深度
对于普通方法,每个方法都对应一个栈帧,普通方法会在局部变量表的第一个槽中存储当前对象的引用
对于静态方法,不依赖对象引用,所以第一个槽会直接存储所需要的局部变量。
对于方法内声明的每个变量,都会在局部变量表中增加一个槽,对于成员变量,不会在局部变量表中增加槽,对于入参,也会增加槽的数量。==> 所以优化没必要的变量,可以有效减少局部变量表的深度。
static boolean isAA(PortalDataEx portalDataEx) {
if (portalDataEx == null) {
return true;
}
byte[] dd = portalDataEx.getData();
return c(dd);
}
// 字节码
0 aload_0
1 ifnonnull 6 (+5)
4 iconst_1
5 ireturn
6 aload_0
7 invokevirtual #12 <com/milkz/common/data/PortalDataEx.getData : ()[B>
10 astore_1
11 aload_1
12 invokestatic #13 <com/milkz/common/data/PortalScriptDownloader.c : ([B)Z>
15 ireturn
例如这个静态方法,如上这样写,局部变量表深度为2,槽0对应portalDataEx,槽1对应dd。操作栈帧是1,因为dd赋值后,直接可以复用portalDataEx的槽位。
如果将其修改一下:
static boolean isAA(PortalDataEx portalDataEx) {
if (portalDataEx == null) {
return true;
}
return c(portalDataEx.getData());
}
// 字节码
0 aload_0
1 ifnonnull 6 (+5)
4 iconst_1
5 ireturn
6 aload_0
7 invokevirtual #12 <com/milkz/common/data/PortalDataEx.getData : ()[B>
10 invokestatic #13 <com/milkz/common/data/PortalScriptDownloader.c : ([B)Z>
13 ireturn
减去了dd变量的声明,此时局部变量表深度为1,操作栈帧深度是1.从字节码就可以看出,下方减少dd变量声明后,字节码少了10和11两步存储dd的操作。性能和内存都优化了。
但是不一定所有情况都去减少变量声明就是好的,看下方例子:
static boolean isAA(PortalDataEx portalDataEx) {
if (portalDataEx == null) {
return true;
}
byte[] dd = portalDataEx.getData();
d(dd);
return c(dd);
}
// 字节码
0 aload_0
1 ifnonnull 6 (+5)
4 iconst_1
5 ireturn
6 aload_0
7 invokevirtual #12 <com/milkz/common/data/PortalDataEx.getData : ()[B>
10 astore_1
11 aload_1
12 invokestatic #13 <com/milkz/common/data/PortalScriptDownloader.d : ([B)Z>
15 pop
16 aload_1
17 invokestatic #14 <com/milkz/common/data/PortalScriptDownloader.c : ([B)Z>
20 ireturn
我们的变量dd后续被使用了多次,大于1次,如果声明变量dd,那么方法getData只会执行一次。但是局部变量表深度为2,操作数栈依然为1.
static boolean isAA(PortalDataEx portalDataEx) {
if (portalDataEx == null) {
return true;
}
d(portalDataEx.getData());
return c(portalDataEx.getData());
}
// 字节码
0 aload_0
1 ifnonnull 6 (+5)
4 iconst_1
5 ireturn
6 aload_0
7 invokevirtual #12 <com/milkz/common/data/PortalDataEx.getData : ()[B>
10 invokestatic #13 <com/milkz/common/data/PortalScriptDownloader.d : ([B)Z>
13 pop
14 aload_0
15 invokevirtual #12 <com/milkz/common/data/PortalDataEx.getData : ()[B>
18 invokestatic #14 <com/milkz/common/data/PortalScriptDownloader.c : ([B)Z>
21 ireturn
如果我们这样修改,不去声明dd,那么每次都会跳到getData方法中拿取一次数据,性能得不到保证,但是局部变量表深度少了,为1,操作数栈为1.
所以如果变量只被使用一次,那么最好不要声明变量,直接使用即可,可以同时提升性能和内存。但是如果后续使用多次,那么就要通过实际业务场景来考虑声明变量了,这样性能可以得到保障。
OK,上边是从局部变量表槽位使用的角度分析了一下栈帧,新问题来了,为什么线程之间存在多线程问题呢?
关于栈
每个线程都会新起一个栈,在线程运行过程中,声明的基本类型,对象等都会保存到栈中,对象保存的是在堆中的引用。当线程生命周期结束时候,栈也就会消失。
由此可以知道,每个线程都有自己的内存空间,并且会为自己的内部变量分配内存。
所以,如果同时存在两个栈,即两个线程,线程中存在相同的一个引用,指向的对象实例是同一个地址,那么当他们同时调用引用的同一个方法时候,两个栈中都会新起一个栈帧,两个栈帧同时操作一个对象地址,所以会出现多线程问题。
关于JVM,重点差不多就这些吧。后续想到新问题,再研究。