目录
类加载的各个阶段
一、加载
将类的字节码载入方法区(1.8后为元空间,在本地内存中)中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法
如果这个类还有父类没有加载,先加载父类
加载和链接可能是交替运行的
当类加载的时候,将类的字节码文件加载到元空间中,是一个用C++描述的InstanceKlass。同时也在堆中存储一个类对象,比如说如果类是Person类i,那么就会在堆中存储一个Person.class的类对象,不管有多少实例对象,类对象只有一个,类对象作为java虚拟机与操作系统之间的一个桥梁,在实体对象的对象头中有8个字节是用来存储person.class这个类对象的地址的,而在类对象Person.class中也持有元空间咋哄InstanceKlass的地址,而InstanceKlass中也持有类对象的地址,当调用类的某些 特定方法例如getmethod、getfield等方法的时候,就会通过类对象找到InstanceKlass,然后执行方法。此外类的静态变量也只有一份,在JDK1.6包括之前,方法区位于堆中,那么静态变量是存放在IntanceKlass后面,而在JDK1.8之后,静态变量存放在类对象Person.class后。
二、连接
1、验证
2、准备
- 为 static 变量分配空间,设置默认值
- static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶 段完成
- 如果 static 变量是 final的,但属于引用类型,那么赋值也会在初始化阶段完成
3、解析
当解析就不会被标记为unresolvedClass,并且能够找到他在内存中的地址
三、初始化
- 发生的时机
- 概括得说,类初始化是【懒惰的】
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
- 不会导致类初始化的情况
- 访问类的 static final静态常量(基本类型和字符串)不会触发初始化
- 类对象.class 不会触发初始化
- 创建该类的数组不会触发初始化
- 类加载器的 loadClass 方法
- Class.forName 的参数 2 为 false 时
类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等!
名称
|
加载哪的类
|
说明
|
Bootstrap ClassLoader
|
JAVA_HOME/jre/lib
|
无法直接访,c++层次的,显示为
null
|
Extension ClassLoader
|
JAVA_HOME/jre/lib/ext
|
上级为
Bootstrap null
|
Application ClassLoader
|
classpath
|
上级为
Extension
|
自定义类加载器
|
自定义
|
上级为
Application
|
Bootstrap ClassLoader:
先介绍一个jvm指令:-Xbootclasspath 表示定义启动类加载的路径
- -Xbootclasspath:<new bootclasspath> 表示覆盖之前的路径(JAVA_HOME/jre/lib)
- -Xbootclasspath/a:<new bootclasspath> 表示追加一个新的路路径在后面
- -Xbootclasspath/p:<new bootclasspath> 表示在前面追加一个新路径
- 1、先创建一个类F
public class F {
static {
System.out.println("我被初始化了");
}
}
- 2、然后在main方法中加载类
public class asdasd {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> f = Class.forName("F");
ClassLoader classLoader = f.getClassLoader();
System.out.println("classLoader:"+classLoader);
}
}
- 3、接着编译并输出查看classLoader
- 4、 由此看到,启动类加载器并不能访问,是一个null值
Extension ClassLoader
首先我自己创建了一个F类
然后这时候打包这个类为jar包,放入jdk/jre/lib/ext目录下
然后开始main方法,加载F类,可以看到是由拓展类类加载器进行加载的
双亲委派
源码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 1检查该类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 2、有上级的话,委派上级 loadClass
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 如果没有上级了(ExtClassLoader),则委派 BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
long t1 = System.nanoTime();
// 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
c = findClass(name);
// 5、记录耗时
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
过程
防止java核心api被串改,避免类重复创建
自定义类加载器
使用场景
- 想加载非 classpath 随意路径中的类文件
- 通过接口来使用实现,希望解耦时,常用在框架设计
- 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤
- 继承 ClassLoader 父类
- 要遵从双亲委派机制,重写 findClass 方法
- 不是重写 loadClass 方法,否则不会走双亲委派机制
- 读取类文件的字节码
- 调用父类的 defineClass 方法来加载类
- 使用者调用该类加载器的 loadClass 方法
自定义类加载器代码:
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
public class MyClassLoader extends ClassLoader {
@Override
public Class<?> findClass(String name) throws ClassNotFoundException {
//指定类加载的路径
String path = "d:\\" + name + ".class";
try {
//创建一个字节输出流
ByteArrayOutputStream os = new ByteArrayOutputStream();
//将类文件拷贝为字节输出流
Files.copy(Paths.get(path), os);
//得到一个字节数组
byte[] bytes = os.toByteArray();
//将字节数组转换成class文件
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundException("类文件未找到", e);
}
}
}
调用
MyClassLoader myClassLoader = new MyClassLoader();
Class<?> f = myClassLoader.loadClass("F");
即时编译器优化:
对于java中的每一条指令,都会先通过虚拟机进行解释成机器码,然后再进行执行。但是如果对一些重复了很多次很多次的热点数据指令,一直在做重复的解释操作,无疑会耗费很多时间
JIT编译的交互过程:具体请参考
当一个方法被调用时,会先检查该方法是否存在被JIT 编译过的版本:如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法计数器的阈值。若超过了,则将会向即时编译器提交一个该方法的代码编译请求;
如果不做任何设置:执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本,整个JIT 编译的交互过程
具体包含以下几种优化
逃逸分析
分析对象的动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递给其他方法,称为方法逃逸。甚至还有可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问到的实例变量,称为线程逃逸。
优化方案
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,就可以为这个变量进行一些高效的优化:如:栈上分配、同步消除、标量替换等。
- 栈上分配
如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将是一个不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁;
- 同步消除
线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,那这个变量的读写肯定不会有竞争,对这个变量实施的同步措施就可以消除掉;
- 标量替换
- 标量定义:它是指一个数据已经无法再分解成更小的数据来表示了,java虚拟机中的原始数据类型都不能再进一步分解,他们就可以称为标量;
- 聚合量定义:如果一个数据可以继续分解,它被称为聚合量;
- 标量替换:如果把一个java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换;那程序真正执行的时候,将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。
例如:
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
point方法并没有发生方法逃逸,那么就可以优化为:
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
方法内联
对于一个方法的执行,会在线程的栈内存中压入栈帧内存,当方法执行完毕后又会将栈帧内存弹出,如果一个方法非常的简单,但是又重复多次执行(热点数据),也就是多次对栈帧的压栈和弹出,虚拟机认为有优化的空间,那么就直接将方法的内容直接放到调用的位置上,从而避免的压栈出栈的开销
例如:
private int add4(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}
优化为:
private int add4(int x1, int x2, int x3, int x4) {
return x1 + x2 + x3 + x4;
}
当启用方法内联的时候,如果在一个方法中多次使用一个成员变量,那么方法在读取成员变量的时候,会用一个局部变量来存储成员变量,这样再以后使用这个成员变量的时候只需要再本地查找局部变量就可以
对象创建
对象创建主流程
1.类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等
2.分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类 加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
这个步骤有两个问题:
- 1.如何划分内存。
- 2.在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
划分内存的方法:
- “指针碰撞”(Bump the Pointer)(默认用指针碰撞)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
- “空闲列表”(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录
解决并发问题的方法:
- CAS(compare and swap)
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
- 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
3.初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4.设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。 HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
32位对象头
64位对象头
5.执行方法
执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。