java,从入土到出棺——4.JVM基础
写在前面:
既然这儿写的是基础了,哈哈,下次的写优化!
其实这个系列的文章更加偏向于总结向,是对以往一些知识做个系统的总结,也是为已经有一定基础的人铺路,提供一个系统的思考。
对新人可能是很不友好的,可能有些人是刚接触,上来就是组合代理的,建议新人上网先做个学习,再把我这个对比记忆,说不定还能发现我的错误呢,说不准哦,三人行,必有我师;如果想和我讨论的,可以直接留言,也可以和我私信,我会很乐意和大家来讨论的。
同时也请大佬们不吝赐教!
1 概念
JVM是可运行JAVA代码的假想计算机,包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收,堆和一个储存方法域。JVM是运行在操作系统上的,它与硬件没有直接的交互。
我们编写的JAVA源文件通过编译器,产生相应的字节码文件,而字节码文件又通过Java虚拟机中的解释器,编译成特定机器上的机器码。
扩展1:
-
机器码:
- 一组特定硬件(不光是计算机、手机、嵌入系统等)能够执行的代码,由0和1组成的二进制序列。
-
指令:
- 指令是把机器码中特定的0和1序列,简化成对应的操作命令,每个指令对应一个相应的二进制0和1组成的代码。
- 主要由于二进制序列可读性太差而发明的。
-
汇编语言:
- 使用助记符表示的指令以及使用他们来编写程序的规则。
-
汇编语言是一种符号语言,比机器码更容易理解和掌握、也容易调试和维护,不过汇编语言从本质上来说还是机器语言,还是一种面向集齐的低级程序设计语言。
每一种平台的解释器是不同的,但是实现的虚拟机是相同的,这也就是Java能够跨平台的原因,当一个程序从开始运行,虚拟机就开始实例化了,多个程序启动就会存在多个虚拟机实例。程序退出或关闭,则虚拟机实例消亡,多个实例之间数据不能共享。
2 运行时数据区域
Java虚拟机在执行Java程序的过程中,会把所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而自动创建,有的区域是依赖用户线程的启动而创建,随着其结束而销毁的。根据《Java虚拟机规范》的规定,Java虚拟机的运行时数据区域如下图所示:
2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以被看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型里,选取另外一行指令,分支,循环,异常处理等基础功能都要依赖此计数器来完成。
虚拟机的多线程服务是通过切换线程来实现的,因此在某个时刻,一个处理器内核只会执行一条线程中的指令。所以,为了切换线程后依旧可以按照顺序执行,每条线程都需要一个独立的程序计数器来储存所执行的位置,各线程间的计数器互不影响。因此,程序计数器属于“线程私有”的内存。
2.2 虚拟机栈
与程序计数器一样,Java虚拟机栈(VM Stacks)也是线程私有的,每一个线程都有自己的虚拟机栈,它的生命周期与线程相同,当线程被创建时,虚拟机栈也同时被创建;当线程被销毁时,虚拟机栈也同时被销毁。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
2.3 本地方法栈
本地方法区和 Java Stack 作用类似,同样,它也是线程私有的,区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为 Native 方法服务。
2.4 堆
堆是Java虚拟机所管理的内存中最大的一块,在虚拟机启动的时候创建,是被所有线程共享的,此区域用来存放对象实例;同时,堆也是垃圾收集器进行垃圾收集的最重要的内存区域。
堆在物理储存上是不连续的、在逻辑上是连续的,大小可调节(-Xmx、-Xms);如果在堆中没有内存完成实例的分配且无法继续扩展的时候,将会抛出OutOfMemoryError异常。
扩展2:
String a=new String(“abc”);
String b=new String(“abc”);
创建了几个对象?
通过new产生一个字符串“abc”的时候,首先是去常量池中查找是否已经存在一个“abc”的对象,没有则创建,然后在堆中再创建一个“abc”的拷贝对象,new了几个,就拷贝几个。也就是说如果常量池中有“abc”,那么一共创建了两个对象,都在堆中;如果常量池中没有“abc”,那么一共创建了三个对象,两个在堆中,一个在常量池中(JDK1.7是个分界线,取决于常量池是否在堆里,下一个扩展会提到);而a,b仅仅是栈中的两个变量名而已!!
下面图我多加两个形式,大家一看图就明白了:
2.5 方法区
方法区和堆一样:在虚拟机启动的时候创建,是被所有线程共享的。它主要用来存放已被虚拟机加载的类信息、常量、以及编译后的方法实现的指令集。
运行时常量池是方法区的一部分。
扩展3:
经常看一些人分不清JDK1.6、JDK1.7、JDK1.8之间关于常量池的一些变化,容易混淆永久代的取消与元空间和方法区的关系,下面我来做个扩展:
-
JDK1.7之前(不包括1.7)是有永久代的,并且静态变量是存放在永久代上的;
-
JDK1.7也有永久代,但是已经将类的静态变量和字符串常量池移出了永久代,保存到了堆(Heap)上;
-
JDK1.7之后(不包括1.7)已经彻底取消了永久代,并且形成了一种新的储存——元空间,类信息、方法、常量保存在本地内存的元空间;但字符串常量池,类的静态变量还是在堆内存中。
下面通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别:
public class TestSpace {
static String test = "TestSpace";
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
String str = test + test;
test = str;
list.add(str.intern());
}
}
}
然后通过命令窗口来查看不同JDK下报错的情况:
JDK 1.6 报错:
JDK 1.7 报错:
JDK 1.8 报错:
于是,我们发现:JDK 1.6的时候,会报“PermGen Space”的内存溢出错误,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermSize 已经不再支持。因此,可以看出: JDK 1.7 和 JDK1.8 将字符串常量由永久代转移到堆中,也就是说字符串常量池移动到堆内存了,并且 JDK 1.8 中已经不存在永久代了,下面为大家演示一下元空间的内存溢出:
(该段代码转自测试元空间内存溢出懒得自己写了😛😛😛)
public interface ClassA {
void method(String input);
}
======================
public class ClassAImpl implements ClassA {
public void method(String name) {
// do nothing
}
}
======================
public class ClassAInvocationHandler implements InvocationHandler {
private Object classAImpl;
public ClassAInvocationHandler(Object impl) {
this.classAImpl = impl;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (Object.class == method.getDeclaringClass()) {
String name = method.getName();
if ("equals".equals(name)) {
return proxy == args[0];
} else if ("hashCode".equals(name)) {
return System.identityHashCode(proxy);
} else if ("toString".equals(name)) {
return proxy.getClass().getName() + "@" +
Integer.toHexString(System.identityHashCode(proxy)) +
", with InvocationHandler " + this;
} else {
throw new IllegalStateException(String.valueOf(method));
}
}
return method.invoke(classAImpl, args);
}
}
======================
public class ClassMetadataLeakSimulator {
private static Map<String, ClassA> classLeakingMap = new HashMap<String, ClassA>();
private final static int NB_ITERATIONS_DEFAULT = 50000;
/**
* @param args
*/
public static void main(String[] args) {
System.out.println("Class metadata leak simulator");
System.out.println("Author: Pierre-Hugues Charbonneau");
System.out.println("http://javaeesupportpatterns.blogspot.com");
int nbIterations = (args != null && args.length == 1) ? Integer.parseInt(args[0]) : NB_ITERATIONS_DEFAULT;
try {
for (int i = 0; i < nbIterations; i++) {
String fictiousClassloaderJAR = "file:" + i + ".jar";
URL[] fictiousClassloaderURL = new URL[] { new URL(fictiousClassloaderJAR) };
// Create a new classloader instance
URLClassLoader newClassLoader = new URLClassLoader(fictiousClassloaderURL);
// Create a new Proxy instance
ClassA t = (ClassA) Proxy.newProxyInstance(newClassLoader,
new Class<?>[] { ClassA.class },
new ClassAInvocationHandler(new ClassAImpl()));
// Add the new Proxy instance to the leaking HashMap
classLeakingMap.put(fictiousClassloaderJAR, t);
}
}
catch (Throwable any) {
System.out.println("ERROR: " + any);
}
System.out.println("Done!");
}
}
JDK 1.8元空间 报错:
看得出来,这次出现了元空间的溢出。
3 JVM类加载机制
3.1 概述
虚拟机加载Class文件(不单单指的是字节码文件,还包括一串二进制的字节流)到内存中,并对数据进行校验、解析和初始化,形成可以被虚拟机直接使用的类型,这整个过程叫做类加载机制。
3.2 类加载的时机
根据《深入理解Java虚拟机》一书,类的生命周期包括了:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备和解析三个部分统称为连接(Linking)。
触发类初始化的条件有四个:
- 遇到new、getstatic、putstatic或invokestatic且此类还未被初始化的时候;
- 使用java.lang.reflect进行反射调用且此类还未被初始化的时候;
- 初始化一个还没初始化的类的子类的时候,需要先将其初始化;
- 虚拟机启动时,需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个类。
3.3 类加载过程
①加载
加载阶段会通过一个类的全限定名来获取定义此类的二进制字节流,将获取到的二进制字节流转化成一种数据结构并放进方法区,在内存中生成一个java.lang.Class对象来代表这个类,作为方法区对于这个类各种数据的入口。有意思的是,获取二进制字节流的方法并不固定,也就是说获取方法是多种多样,并且适用于很多开发场景的:包括从jar包、war包读取;从网络中获取;动态代理;由JSP文件生成等……
②验证
根据既定的规则来确保Class文件的字节流信息符合虚拟机的要求。包括文件格式验证、元数据验证、字节码验证、符号引用验证。
③准备
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使 用的内存空间。
- 此时被分配的仅仅是静态变量,而不是实例变量,实例变量将随着对象实例一起分配在Java堆中初始值通常情况下是数据类型的零值。
- 假如定义一个静态变量 public static int value = 123;那么value在准备阶段初始值为0而不是123。
- 被final修饰的变量在准备阶段就初始化为属性所指定的值。例如: public static final int value = 123;那么value在准备阶段初始值就是123。
数据类型的零值:
数据类型 | 零值 |
---|---|
byte | (byte)0 |
char | ‘\u0000’ |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
boolean | false |
引用数据类型 | null |
④解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
-
符号引用:以一组符号来描述引用的目标,符号可以是任何形式的字面量。
-
直接引用:指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
⑤初始化
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。
初始化阶段是执行类构造器方法的过程。方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子方法执行之前,父类的方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成()方法。
3.4类加载器
前面提到类加载过程中的加载阶段有这么一步“通过一个类的全限定名来获取定义此类的二进制字节流”,若是将此放到JVM外部去实现,那么实现这个步骤的代码块被称为“类加载器”。
JVM 提 供了 3 种类加载器:
-
启动类加载器(Bootstrap ClassLoader)
负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被 虚拟机认可(按文件名识别,如 rt.jar)的类。
-
扩展类加载器(Extension ClassLoader)
负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类 库。
-
应用程序类加载器(Application ClassLoader)
负责加载用户路径(classpath)上的类库。
-
JVM 通过双亲委派模型进行类的加载
3.5双亲委派
双亲委派模型的工作过程为:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的加载器都是如此,因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法完成该加载请求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。
o(╥﹏╥)o类加载过程我实在想不出啥扩展的好玩儿的了,都是干巴巴的,但是也很重要,所以,忍痛看吧🤣🤣🤣🤣