JVM学习笔记内存与垃圾回收篇
Posted 嘉然今天吃甚么
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM学习笔记内存与垃圾回收篇相关的知识,希望对你有一定的参考价值。
一、JVM与java体系结构
1 虚拟机的结构
2 java的编译
有句话说java是半编译半解释型语言,因为从*.java到*.class文件的过程是编译。而*.class文件的运行是解释。
但其实*.class文件的运行过程是半编译半解释的。有些需要反复用到的字节码是直接编译成机器指令来执行的。就好比域名的解析,常用访问的域名解析是直接通过本地域名服务器,不常用的才会自顶向下层层解析。例如for循环中的代码是要重复利用的,就把这段字节码编译成机器指令缓存起来,每次循环都调用缓存区中的机器指令,而不需要进行解释。如图:
3 jvm的指令集
指令集架构有基于栈的指令集架构和基于寄存器的指令集架构。
jvm中使用的就是基于栈的指令集架构。它的好处是不需要硬件支持,可移植性高,零地址指令多。但性能不及寄存器式架构(如x86汇编)
栈式架构必然是大量使用到栈。例如我们将如下代码进行反汇编:
package com.spd.jvm;
public class Test
public static void main(String[] args)
int i = 2;
int j = 3;
int k = 2 + 3;
得到的结果是:
iconst_2 // 将int型数2压栈
istore_1 // 将栈顶int型存入本地变量1
iconst_3 // 将int型数3压栈
istore_2 // 将栈顶int型存入本地变量2
iconst_5 // 将int型数5压栈
istore_3 // 将栈顶int型存入本地变量3
return // 从当前方法返回void
而对x86汇编而言是这么写的:
mov ds : [0], 2 ; 设ds : [0]为i
mov ds : [4], 3 ; 设ds : [4]为j
mov eax, ds : [0]
add eax, ds : [4]
mov ds : [12], ax ; 设ds : [12]为k,k = ax = i + j
ret
由此可见,确实比起x86汇编更多使用到了栈。另外也能看出来同一功能栈式架构比寄存器式架构需要用更多指令,但是他的指令集更小。
二、类加载子系统
1 类加载子系统的结构
2 类加载子系统的作用
类加载子系统只负责类的加载,而类能否运行由执行引擎决定。
3 类加载的加载过程
① 通过一个类的全限定名获取定义此类的二进制字节流
② 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
③ 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
4 类加载的链接过程
① 验证:验证魔数及版本号。
② 准备:为类变量分配内存并设置为他们的初始值。初始化阶段才赋值。(不会分配final修饰的static,因为他们在编译过程就已经分配了。准备阶段只会进行显式初始化。也不会为实例变量分配初始化。)
package com.spd.jvm;
public class Test
private static int age = 18; // prepare: age = 0 -> initial: age = 18
private static char sex = '男'; // prepare: sex = '\\0' -> initial: sex = '男'
public static String name = "spd"; // prepare: name = null -> initial: name = "spd"
public static void main(String[] args)
System.out.println("Hello world!");
③ 解析:将常量池中的符号引用转换成直接引用。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是他们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能简介定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
5 类加载的初始化过程
① <clinit>()方法:
初始化过程实际上就是执行类构造器方法<clinit>()的过程。
该方法不等同于类的构造器(类的构造器是<init>()函数),他不需要定义,是javac编译器自动收集类中的所有类变量类中的所有变量的赋值动作和静态代码块中的语句合并而来。因而如果类中没有静态代码块也没有静态变量,那就没有<clinit>()方法。
有父类,则先执行父类<clinit>()
虚拟机必须保证一个类的<clinit>()方法在多线程中被同步加锁。
package com.spd.jvm;
public class Test
private static int a = 1;
static
a = 2;
b = 20;
public static int b = 10;
public static void main(String[] args)
System.out.println("Hello world!");
对这段代码反汇编查看字节码中的<clinit>()方法:
iconst_1 // 将int型1压入栈顶
putstatic #5 // 为指定类的静态域赋值,此处#5即为a(不知道需不需要弹栈)
iconst_2 // 将int型2压入栈顶
putstatic #5 // 为指定类的静态域赋值
bipush 20 // 将单字节常量值(-128 ~ 127)压入栈顶,此处为20
putstatic #6 // 为指定类的静态域赋值,此处#6即为b
bipush 10 // 将单字节常量值(-128 ~ 127)压入栈顶,此处为10
putstatic #6 // 为指定类的静态域赋值
return // 从当前方法返回空
如上便是<clinit>()方法的作用。那为什么静态代码块中b的赋值语句明明在b的声明语句之前,静态代码块还能给b赋值呢?因为准备阶段就给静态变量分配好了内存空间,赋值的过程是在其后的初始化过程中完成的。
prepare :
a = 0, b = 0;
initial:
a = 1;
static
a = 2;
b = 3;
b = 4;
但是可以在静态代码块中前向赋值,却不可以前向调用。例如如下代码就会报错:
package com.spd.jvm;
public class Test
private static int a = 1;
static
a = 2;
b = 20;
System.out.println(a);
System.out.println(b); // <- 此处报错
public static int b = 10;
public static void main(String[] args)
System.out.println("Hello world!");
6 类加载器的分类
jvm支持两种类型的类加载器,引导类加载器和自定义类加载器。凡是派生于抽象类ClassLoader的类加载器都被划分为自定义类加载器。(扩展类加载器和系统类加载器等)
引导类加载器、扩展类加载器、系统类加载器。三者并不存在上层下层关系,更不存在继承关系。而是呈现包含关系的。就像文件的层级目录一样。
另外引导类加载器是用c/c++语言编写的,其他类加载器都是用java写的。
我们可以用代码演示他们之间的异同。
package com.spd.jvm;
public class Test
public static void main(String[] args)
// 获取系统类加载器
ClassLoader sys = ClassLoader.getSystemClassLoader();
System.out.println(sys);
// 获取其上层:扩展类加载器
ClassLoader ext = sys.getParent();
System.out.println(ext);
// 试图获取其上层:引导类加载器
ClassLoader bootstrap = ext.getParent();
System.out.println(bootstrap); // 获取不到,打印空
// 自定义类默认使用系统类加载器
ClassLoader loader1 = Test.class.getClassLoader();
System.out.println(loader1);
System.out.println(loader1 == sys);
// java核心api中的类是用引导类加载器加载的。
ClassLoader loader2 = String.class.getClassLoader();
System.out.println(loader2);
① 引导类加载器:由C/C++实现,嵌套在JVM内部。他用来加载jdk的核心类库。并不继承自ClassLoader,没有父加载器。只加载包名为java javax sun开头的类。
运行以下程序,获得引导类加载起所能加载地api的路径。
package com.spd.jvm;
import sun.misc.Launcher;
import java.net.URL;
public class Test
public static void main(String[] args)
System.out.println("-------引导类加载器-------");
/* 获得引导类加载起所能加载地api的路径。 */
URL[] urls = Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls)
System.out.println(url.toExternalForm());
运行结果:
-------引导类加载器-------
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/classes
从这些路径中随便找一个jar包,发现其中有一个类,名为Provider,进行测试:
package com.spd.jvm;
import java.security.Provider;
public class Test
public static void main(String[] args)
ClassLoader classLoader = Provider.class.getClassLoader();
System.out.println(classLoader);
输出null,证明该类使用引导类加载器。
② 扩展类加载器:虚拟机自带、java语言编写。父加载器为启动类加载器。用来从jdk的安装目录jre/lib/ext子目录下加载类库。自定义类放在该目录中也是同等待遇。
一个套路。
package com.spd.jvm;
import sun.security.ec.CurveDB;
public class Test
public static void main(String[] args)
System.out.println("-------扩展类加载器-------");
String dirs = System.getProperty("java.ext.dirs");
/* 获取路径 */
for (String path : dirs.split(";"))
System.out.println(path);
/* 从上面获取到的路径里面随便取一个查看他的类加载器 */
ClassLoader classLoader = CurveDB.class.getClassLoader();
System.out.println(classLoader);
③ 系统类加载器:派生于ClassLoader,负责加载环境变量classpath或系统属性java.class.path指定路径下的类库。
证明过程略。
日常开发中,上文提到的三种类加载器就够用了。必要时也可以使用自定义类加载器。
自定义类加载器应用于:隔离加载类、修改类的加载方式(如动态加载)、扩展加载源(可以从数据库等其他来源加载类)、防止源码泄露(反反编译,字节码文件经过加密,加载类时需进行解密)等。
7 双亲委派机制
若某个憨批在项目中建了一个包,名为java.lang,其中还有有个类还刚好叫String,那此时运行程序时会加载jdk中的String还是憨批自己写的String呢?不妨试试。(憨批竟是我自己)
package java.lang;
public class String
static
System.out.println("I am not String in jdk.");
package com.spd.jvm;
public class Test
public static void main(String[] args)
String str = new String();
System.out.println("Hello world!");
Hello world!
Process finished with exit code 0
运行结果表明加载器加载的是jdk中的String。这就是通过双亲委派机制实现的。
也就是一个类加载器收到了类加载请求他并不会立即去加载,而是委托给父类加载器去加载。若父类能加载成功那就完事了,否则交给子类加载器去加载。
所以上面那个例子的过程应该一看是String类是自定义的,先交给系统类加载器去执行,系统类加载器委托给扩展类加载器,扩展类加载器又委托给引导类加载器,最终由引导类加载器一看包名是java打头,于是进行加载。
如果是一般的自定义类,那就是从系统到引导层层委托,完了引导扩展都不太行,最终还是交给系统类加载器管。
那双亲委派机制的好处是啥?一个是避免类的重复加载,再一个是避免核心api被随意篡改(比如憨批自定义的String)
另外在提一嘴沙箱安全机制,自定义一个java.lang.String,并写上main方法,运行后说没有main方法,因为加载的是jdk中的String类,这是为了保护Java核心源代码,这就是基于沙箱安全机制。
8 类的主动使用与被动等待
两个类是否相等首先需要判断完整类名是否相等,再判断对应的ClassLoader是否相同。
换句话说,若两个类对象来自于同一个class文件,而具有不同的类加载器,那也算是不相等的类对象。
类的主动使用和被动使用区别在于会不会导致类的初始化。也就是说被动使用是不会执行<clinit>()方法的
三、运行时数据区与线程概述
1 运行时数据区的结构
其中方法区和堆是随着虚拟机的创建而创建摧毁而摧毁,为各个线程所共用。而程序计数器(PC)、本地方法栈(NMS)、虚拟机栈(VMS)则是随着某个线程的创建而创建摧毁而摧毁。
垃圾回收大部分是发生在堆区,但也有一些发生在 方法区/元空间/永久代 中。另外对于JIT编译缓冲,有的书中说是在方法区中,有的书上说是独立于方法区存在,众说纷纭。
2 一些比较重要的守护线程
要知道执行Java程序的时候,并不是只有main方法这一个线程。还有很多很多虚拟机自动开启的守护线程。比较重要的有以下几个:
① 虚拟机线程:负责线程栈收集,线程栈挂起,以及偏向锁的撤销。
② 周期任务线程:是时间周期事件的体现(如中断),用于周期性操作的调度执行。
③ GC线程:顾名思义,负责垃圾回收。
④ 编译线程:负责JIT编译,将常用字节码编译为机器指令并缓存。
⑤ 信号调度线程:负责接受信号并发送给JVM,内部通过调用适当方法进行处理。
四、运行时数据区 — 虚拟机栈
1 栈的运行原理
一个时间点上,只会有一个正在活动得到栈帧。该栈帧叫做当前栈帧,与之对应的方法叫做当前方法,再与之对应的类,叫做当前类。就有点像栈顶,懂吧。
栈帧的内部主要是局部变量表、操作数栈、其次还有动态链接、方法返回地址、和一些附加信息(有时会忽略附加信息)。有时还会将动态链接、方法返回地址、附加信息称为帧数据区。
不同的线程之间的方法是无法共享的,也就是他们的栈帧是无法共享的。
当前栈帧无论是使用return或是抛出异常,都会弹栈。
2 栈帧的内部结构 — 局部变量表
局部变量表是一个数字数组。局部变量表中最基本的存储单元是slot(变量槽)。32位以内的数据,每个数据指针一个变量槽,如byte short char boolean。而long和double则要转换成两个变量槽。
局部变量表的大小在编译期间就定下来了。方法运行期间不会修改。我们随便写一个方法:
public static void main(String[] args)
Test test = new Test();
int n = 16;
这里面起始pc就是字节码指令的相对地址。长度则是该变量可以起作用的范围,声明完可不就能起作用了。所以同一个方法内,起始PC和长度之和大部分情况下是一个常量。
另外,成员方法的局部变量表中必然是有一个this的,就在局部变量表的第一个位置上。这也算是解释了static方法为什么不能使用this关键字。声明一个没有任何内容的成员方法func()并查看局部变量表:
顺带插一嘴其他的。
方法名、描述符与访问标志。
异常表:
杂项:操作数最大深度、局部变量最大槽数、字节码长度等。
行号表,即class中字节码指令相对地址和java源代码中的行号的对应关系。
3 共用变量槽
这是我认为一个很有趣的地方,先看如下代码:
public void func()
int a = 0;
int b = 0;
b = a + 1;
int c = a + 1;
查看他的方法属性和局部变量表:
我们发现虽然有四个局部变量,但局部变量表长度却是3,这是为什么的,我们发现变量b从 pc = 4 时进入局部变量表,在 pc = 8 时退出。而原本b的位置后面被c重复利用了(他俩index都是2),所以才会出现这种状况。
为了防止出现占内存溢出,我们在开发中也可以通过妥善利用大括号来缩小栈帧大小。
4 java中的变量
Java变量分类有两种分法:
分法一:按照数据类型分
- 基本数据类型
- 引用数据类型
分法二:按照在类中的位置分:
-
成员变量:在使用前,都经过默认初始化复制
- 类变量(静态变量,被static修饰的成员变量):linking的prepare阶段给变量默认赋值—>initial阶段:给变量显示赋值即静态代码块赋值
- 实例变量(没有被static修饰的成员变量):随着对象的创建,会在堆空间分配实例变量空间,进行默认赋值
-
局部变量:使用前必须显示赋值,否则编译不通过。
5 栈帧的内部结构 — 操作数栈
就和数据结构课设的表达式求值里面的操作数栈是一个审儿的。
比如下面这个例子:
public void func(String[] args)
int a = 20;
int b = 30;
int c = a + b;
对应字节码:
0 bipush 20 // 将20压入操作数栈
2 istore_1 // 将操作数栈栈顶int型元素弹栈弹入局部变量一(局部变量表中索引为一)
3 bipush 30 // 将30压入操作数栈
5 istore_2 // 将操作数栈栈顶int型元素弹栈弹入局部变量二
6 iload_1 // 将int型的本地变量一压入操作数栈栈顶
7 iload_2 // 将int型的本地变量二压入操作数栈栈顶
8 iadd // 将栈顶两个int型数弹出并相加,然后将结果压入栈顶
9 istore_3 // 将操作数栈栈顶int型元素存入局部变量三
10 return // 返回
上面没有出现0号局部变量,因为0号是this。
我们发现操作数栈分别在pc = 0、pc = 3、 pc = 8时入栈。又分别在pc = 2、pc = 5弹栈,在pc = 8时弹两次栈。用伪代码表示即:
0: operandStack.push(20);
2: localVariableTable[1] = operandStack.pop();
3: operandStack.push(30);
5: localVariableTable[2] = operandStack.pop();
6: operandStack.push(localVariableTable[1]);
7: operandStack.push(localVariableTable[2]);
8: operandStack.push(operandStack.pop() + operandStack.pop());
9: localVariableTable[3] = operandStack.pop();
10: return;
那么我们可以推断出来操作数栈的最大深度就是2了。
如果方法有返回值。如下:
public int func()
int a = 20;
int b = 30;
int c = a + b;
return c;
那就得把返回值存入栈顶,如下:
0 bipush 20
2 istore_1
3 bipush 30
5 istore_2
6 iload_1
7 iload_2
8 iadd
9 istore_3
10 iload_3
11 ireturn
用伪代码表示为:
0: operandStack.push(20);
2: localVariableTable[1] = operandStack.pop();
3: operandStack.push(30);
5: localVariableTable[2] = operandStack.pop();
6: operandStack.push(localVariableTable[1]);
7: operandStack.push(localVariableTable[2]);
8: operandStack.push(operandStack.pop() + operandStack.pop());
9: localVariableTable[3] = operandStack.pop以上是关于JVM学习笔记内存与垃圾回收篇的主要内容,如果未能解决你的问题,请参考以下文章