首页 > 嗟来之食 > 深入理解java虚拟机(4)—类加载机制 – Joyfulmath –
2016
08-10

深入理解java虚拟机(4)—类加载机制 – Joyfulmath –

  类加载的过程包括:
  加载class到内存,数据校验,转换和解析,初始化,使用using和卸载unloading过程。
除了解析阶段,其他过程的顺序是固定的。解析可以放在初始化之后,目的就是为了支持动态加载。
从java开发者来讲,我们并不关心具体细节,只要知道整个流程以及每个流程大体干了那些事情。
每个流程具体对开发代码会有那些影响就可以了。
类的加载流程
1.加载loading
  在加载过程中,虚拟机需要完成3件事情:
1)通过一个类的全限定名来获得此类的二进制字节流。
2)将这个直接流的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的class对象,作为方法区这个类的数据访问入口。
2.验证
验证是虚拟机非常重要的一步,其目的是为了确保class文件的字节流符合java虚拟机自身的要求,不会导致虚拟机崩溃。
java语言本身是比较安全的语言,它没有数组越界等情况的发生。But,class语言并不是一定由java语言产生的。甚至于,
可以直接使用16进制工具编写class文件。而这些文件就不能保证class文件的规范性。
大致分成4个阶段的验证过程:文件格式验证、元数据验证、字节码验证和符号引用验证

文件格式验证:
比如是否以魔数开头,主次版本号是否在虚拟机可处理范围之内,常量池是否有不支持类型等。
经过这个阶段的验证之后,字节流才会进入内存的方法区进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的。

元数据验证:
对字节码描述的信息进行语义分析,以保证其描述的信息符合JAVA语言规范的要求,这个阶段可能包括的验证点有:
这个类是否有父类,父类是否集成了不允许继承的类,如果不是抽象类是否实现了其父类或接口中要求实现的所有方法,类中的字段和父类是否有矛盾

字节码验证:
最复杂的一个解读那,主要工作是进行数据流和控制流分析。这阶段对类的方法体进行校验分析,保证该方法在运行时不会做出危害JVM安全的行为,例如:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,保证跳转指令不会跳转到方法体以外的字节码指令上,保证方法体中的类型转换是有效的。。。
这个验证并不能保证一定安全(停机问题,通过程序去校验程序逻辑是无法做到绝对准确的)
1.6加入StackMapTable功能对这个阶段做了优化,提高速度,但这个StackMapTable也可能被篡改,可以通过启动参数来关闭这个选项。

符号引用验证:
这个阶段发生在虚拟机将符号引用转化为直接引用的时候。这个转化动作将在连接的第三个阶段—-解析阶段中发生。
可以看作是对类自身以外的信息进行匹配性的校验。
比如:符号引用中通过字符串描述的全限定名是否能找到对应的类,是否存在所描述的方法和字段。。。。
如果无法通过符号验证,将会抛出一个Java.lang.IncompatibleClassChangeError异常的子类,比如Java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError
可以使用启动参数来关闭大部分类验证措施,缩短虚拟机类加载时间。

3.准备
准备阶段就是为类的变量正式分配内存并设置初始值。这个初始值与初始化不是同一个概念。
比如

public static int value = 12;

这个阶段value的值为0 而不是12。value赋值为12的阶段
是在初始化的过程中出现的。

java所有的基本类型都赋值为零值。(简单来说就是0 or null,0.0f,false等)

4.解析
解析是java语言面向对象的基础。
解析的过程是将常量池里面的字符引用替换为直接引用的过程。
符号引用是 一组以符号来描述所引用的目标。各种虚拟机的内存布局可以各不相同,但是字面量的形式有虚拟机规范严格规定。
直接引用就是对虚拟机内存布局的直接描述。
所以引用的目标必须已经加载到内存里面了。
1).类或接口的解析
类和接口的解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下三个步骤:

如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。
如果C是数组类型,并且数组的元素类型是对象,则按照1的情况处理。如果元素类型不是对象,则由虚拟机生成一个代表此数组维度和元素的数组对象。
如果上述步骤没有异常,C在虚拟机中世纪已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具有对D的访问权限,如果没有则抛出java.lang.IllegalAccessError异常。
2).字段解析
大体情况如下:

class D{

public D(C c)
{
string a = c.a;
}
}

D 需要加载C.a 字段,首先,需要加载的是C类的解析内容。然后关键部分就是java语言继承的东东了。
如果C类本生就含有a的字段,直接返回a的直接引用。
搜索C类的接口,按照继承关系从上到下搜索各个接口已经父接口,直到找到a字段。如果没有
if C is not the java.lang.Object,同上,搜索C类的父类,如果有,就使用该字段的直接引用。如果没有
也就是C类及其相关类or接口没有这个字段,查找失败。
如果找到,还需要进行权限验证。
如果接口 & 类 都包含相同名的字段,java程序员有时候会无法判断到底使用的是哪个字段。
所以编译器一般会拒绝这种情况的发生。
以下是使用androidstudio 实验的结果:

public interface ICoo {
public static int A = 1;
}

public abstract class CooAbstruct {
public int A = 22;
}

public class Coo extends CooAbstruct implements ICoo {

// public int geta()
// {
// return A;
// }
}

public class Doo {

public Doo(Coo c)
{
int a = c.A;
}
}

E:\GitHub\jvmdemo\app\src\main\java\com\joyfulmath\myapplication\Doo.java:13: 错误: 对A的引用不明确, CooAbstruct中的变量 A和ICoo中的变量 A都匹配 int a = c.A; ^1 个错误
可以看到,编译器明确 无法区分A到底是使用哪个字段。
在C++的多继承中,类似的情况在使用时需要明确到底是使用哪个子类的字段。

3)类的方法加载:
同样使用C类来描述这个过程:
类方法和接口方法 常量类型是分开的。所以如果C类方法发现是一个接口的方法的话,直接回抛出异常。类型检测。
直接在C类里面寻找是否有匹配的字符描述的方法。没有就继续
在C类的父类里面递归寻找,没有就继续
在C类的接口里面递归寻找,找到,说明本方法未被实现,C类是抽象类。抛出异常
都没有找到,nosuchmethod。
如果找到有效的匹配方法后,检查权限。
4)接口的加载方法
过程同类的方法基本一致。只是不需要进行权限检查。

5.初始化
初始化和准备阶段是不同的过程,而且是java程序员最关心的部分。
1.必须初始化的情况
java虚拟机规范 规定了5种 (有且仅有)情况下,必须进行初始化的操作。
1)遇到new,getstatic,putstatic,invokestatic 这4条指令的时候。对应场景:
实例化一个类,读取或者设置一个类的静态字段,调用一个类的静态方法时候。
2)使用反射方法调用的时候,需要先初始化。
3)当初始化一个类时,需要先初始化父类。
4)当虚拟机启动时,需要指定一个启动类(main类),虚拟机会首先初始化这个类。
5)当使用jdk1.7动态语言时候,具体情况本文不做分析。
一下使用几个demo来说明我们容易误解的地方:

public class MainActivity extends Activity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
TraceLog.i(String.valueOf(SubClass.value));
}

}

public class SubClass extends SuperClass {
static {

TraceLog.i("subclass init!");
}
}
public class SuperClass {
static {
TraceLog.i("SuperClass init!");
}

public static int value = 12;
}

结果log:
05-08 10:10:33.783 868-868/com.joyfulmath.myapplication I/SuperClass: <clinit>: SuperClass init! [at (SuperClass.java:13)]05-08 10:10:33.783 868-868/com.joyfulmath.myapplication I/MainActivity: onCreate: 12 [at (MainActivity.java:19)]
是的,只有父类被初始化了,子类没有初始化,why?
应为value定义的父类,所以只需要初始化父类就可以的。

public class SuperClass {
static {
TraceLog.i("SuperClass init!");
}

public static int value = 12;

public SuperClass()
{
TraceLog.i("SuperClass construct");
}
}

实例化construction函数没有走到,所以没有实例被创建!!!but,我们在看log,<clinit> 这个是神马?这个就是打印SuperClass.init所在的函数!!!
这个等到下面在讲,我们继续我们的demo。

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// TraceLog.i(String.valueOf(SubClass.value));
TraceLog.i();
SuperClass[] a = new SuperClass[10];
}

05-08 10:22:33.100 12438-12438/com.joyfulmath.myapplication I/MainActivity: onCreate: [at (MainActivity.java:21)]

what? 对于SuperClass 没有一行log,也就是根本没有初始化SuperClass。
它触发了一个类为“[xxx.Superclass“ , 这是SuperClass对应的数组类,是由虚拟机自动生成的。

TraceLog.i(a[0].toString());

Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.Object.toString()' on a null object reference
at com.joyfulmath.myapplication.MainActivity.onCreate(MainActivity.java:23)
at android.app.Activity.performCreate(Activity.java:5961)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1129)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2364)

a[0] 居然是null? 是的,数组a里面都是null。对的a只是一个数组,a的类型为”[xxx.Superclass“ 不是SuperClass。所以数组不会自动初始化元数据。
常量。
常量存放在常量池里面,所以对常量的引用在编译阶段就已经被优化。
下面我们来讲讲<clinit> 这个东东。
静态代码块+所有类的变量的赋值动作。
这里有一点需要强调:编译器收集的顺序与由源代码在文件中的顺序是一致的。
<clinit>()方法是由编译器自动收集类中的所有类变量的复制动作和静态语句块中的语句合并而成。编译器收集的顺序和语句在源文件中出现的顺序一致,静态语句块中只能访问到定义在它之前的变量,定义在它之后的变量,只能赋值,不能访问
<clinit>()方法与类的构造函数<init>()不同,不需要显式的调用父类构造器,虚拟机会保证父类的<clinit>()在子类的之前完成。因此,虚拟机执行的第一个<clinit>()方法肯定是java.lang.Object.
由于父类<clinit>()方法先执行,也就意味着父类中定义的静态语句要优先于子类的变量赋值操作。
先看个例子来说明上述概念:

public class SuperClass {
public static int A = 1;

static {
A = 2;
TraceLog.i("SuperClass init!");
}

public static int value = 12;

public SuperClass()
{
A = 3;
TraceLog.i("SuperClass construct");
}
}

public class SubClass extends SuperClass {
public static int B = A;
static {

TraceLog.i("subclass init! B:"+B);
}
}

如图所示:父类里面A有3个地方赋值。那么B到底是多少呢?
subclass 在给B赋值以前,会首先走完superclass的<clinit>.所以 A的值是2.
so, B输出的值 就是2.在B赋值的时候,构造函数没有调用。(construction操作只有在实例化的时候,会被调用!)

<clinit>()方法并不是必须的,如果一个类没有静态语句块也没有对变量赋值操作,就不会生成
接口中不能使用静态语句块,但仍有变量初始化赋值的操作,因此也会生成<clinit>()方法,但与类不同的是,接口的<clinit>()方法不需要执行父接口的<clinit>()方法。只有当父几口中定义的变量被使用时,父接口才初始化,另外,接口的实现类在初始化时一样不会执行接口的<clinit>()方法。
虚拟机会保证一个类的<clinit>()方法在多线程环境中正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都会阻塞,直到该方法执行完,如果在一个类的<clinit>()方法中有耗时很长的操作,可能会造成多个进程阻塞,在实际应用中,这种阻塞往往很隐蔽。
参考:
《深入理解java虚拟机》 周志明著

最后编辑:
作者:
这个作者貌似有点懒,什么都没有留下。

留下一个回复