以本例为开篇一遍研究如何学习,一遍把他学好
需要参考的准备数据:
《深入理解JAVA虚拟机》
《Java并发编程的艺术》
需要参考的知识点:
字节码结构
jdk8 matespace的特定
知识的记录方式:
- 查看博客,把没有遇见过的或者觉得比较经典的博文段落摘录
- 自己的理解以条目的形式展示
- 知识误解标记
- 知识盲区标记
- JAVA内存模型这个知识点基本上每本书都会讲解,最好的方式将每本的书的这一章都读一下,然后摘录重要的知识点,通过反复和串联达到效果。
疑问:
java类字节码文件的格式定制规则?
java字节码如何体现面向对象原则?
类如何存储,对象如何存储?
类在内存中如何与对象关联?
指令和数据如何在虚拟机中体现?
类支持卸载吗?
类文件如何避免重复读取?
类和对象的内存分配时如何计算他们需要的空间大小?
为什么类初始化需要分很多阶段?
为什么类和对象的内存分配需要分那么多个区?
JVM如何根据类字节码创建对象?
类和对象在并发系统中如何创建?
类的各个数据区如何避免多线程访问安全问题?
类和对象哪些数据涉及到共享内存问题?
范型在字节码中如何体现?
重要笔记:
-
JDK对字节码文件class具有向前兼容的特性,比如JDK7支持JDK7以及之前JDK版本的字节码文件。
-
JDK是整个JAVA的核心,包括了Java运行环境(Java Runtime Envirnment),一堆Java工具(javac/java/jdb等)和Java基础的类库(即Java API 包括rt.jar)。
-
Java运行环境(Java Runtime Envirnment)包括JVM和JAVA核心类库和支持文件
-
类文件的组成包括 JVM 指令集,符号表以及一些补助信息。
-
JVM 对上层的 Java 源文件是不关心的,它关注的只是由源文件生成的类文件( class file )。
-
JVM 的主要工作是解释自己的指令集(即字节码)并映射到本地的 CPU 的指令集或 OS 的系统调用。
-
常量池的第1个常量的索引号为0,表示“不存在任何常量引用”
-
常量池存放着“字面量”以及“符号引用”。
-
字面量指“数值常量”以及“字符串”
-
符号引用则是编译器的产物,属于用户定义的“类名”、“接口”、“字段名与字段描述符”、“方法名与方法描述符”
-
类的依赖关系的体现,是在虚拟机加载类的时候进行动态链接,而不是编译的时候。因此,所有的类在编译时不会处理类文件在内存的布局信息,只有到了类加载的时候才会涉及“如何把符号引用翻译到具体的内存地址中”。
-
在字节码里面,只有两种数据类型,分别是无符号byte类型和表类型,而表类型是由多个无符号类型以及“表类型”组合而成。在常量池中的每一个常量都是一个表。
-
一个class文件其实也是一张表。
-
我要读懂一个字节码文件的表类型,还需要一个表结构定义,这个表结构定义在JVM规范里面定义。
-
常量池中存放的数据类型可以从JVM规范中的 “常量池的项目类型” 表中得知。比如:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K04mT5Cf-1579504945753)(https://i.loli.net/2019/04/26/5cc2c3317236b.png)]
-
常量池就好比类字节码文件中的数据库。
-
字节码文件的排版就好比一颗树,通过前序遍历输出后的结果。每一个树的节点开头都有一个tag字段用于表示这个节点的类型(这个tag字段类型为u1,无符号单字节类型)。
-
因为class文件的大部分字符串都采用CONSTANT_Utf8_Info类型来描述,所以java的类名、方法名等的支持UTF8所有字符集,比如中文。
-
因为CONSTANT_Utf8_Info类型的长度是用u2类型,即无符号双字节,所以JVM的类中如果定义了长度超过65535的字符串,将无法编译。比如定一个String longStr = “abcd…123”;如果longStr变量超过65535个字符,将会编译错误。
-
java不支持多重继承,所以他的字节码规范中的super_class是一个u2类型数据,这个u2类型的数据的索引值为该类型父类的全限定符常量索引号。
-
jvm规范中,接口,抽象类,final类,注解,和枚举都是class,所以他们都有一个或者0个父类、n个接口。
-
final和volatile修饰一个字段的field的时候,被JVM规定只能二选一。
-
field字段的描述符,即这个字段的类型,而方法的描述符则是由0到n个参数类型以及返回值类型连接构成的一个字符串。
-
父类的字段不会出现在子类的字节码文件中
-
内部类的字节码会添加指向外部类实例的字段。
-
java语言中字段不能使用相同的名称(即字段的类型不一样,也不许字段同名),在字节码中,如果两个字段的描述符(类型)不一样,允许名称相同。
-
关于方法重载,在java语言中只要java语言的方法特征签名不一样也可以存在两个同名的方法,JAVA语言的特征签名包括“方法名称,参数顺序,参数类型”。而字节码的特征签名除了上面这些还包括了“返回值以及受查的异常表”。显然字节码在重载方面的扩展比java的要强大很多。
-
每一个method_info中由一个attribute数组,这个数组中有一个code类型的属性用来存放编译后的方法代码指令集。而lineNumbertable用来存放编译前和编译后的代码行号对应关系。
-
每一个method_info中由一个attribute数组,而LocalVariabletable存放了方法局部变量的类型描述,这些局部变量会被加载到线程本地栈空间。
-
每一个方法表都有一个max_locals在计算本地变量的最大需要的空间大小,这个大小并非简单的把所有本地变量的空间加起来,因为有部分变量可以复用内存空间。内存的最小分配单位为一个slot,一个slot大小为4个字节。max_locals大小由javac编译器计算得出。
-
虚拟机的指令的数据类型为u1,即一个指令占用一个字节空间。一个字节的寻址空间为0~255,所以jvm的指令集最大为255条。jdk8版本为止约有200条指令。
-
虽然方法表的code_length属性的类型是u4,即code_length的最大值为232-1,表示有一个jvm方法允许有232-1指令。但实际上JVM规范做了强制限制,code长度最多不能超过65535个字节。所以一旦超过了,符合JVM标准的javac将会爆编译错误。(这个错误通常会出现在编译过长的JSP代码时出现)
-
字节码文件中所有的方法都是一个普通的静态方法。如果该方法属于实例对象的成员方法,那么该方法第一个参数一定为this,即该方法至少有一个参数。
-
每一个方法表都有0~n个异常表,每个异常表的格式为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cwGmsqta-1579504945754)(https://i.loli.net/2019/04/26/5cc2c3312cafd.png)]
其中start_pc表示异常检测开始代码行号,end_pc为异常检测结束的行号,catch_type为异常类型,handler_pc为异常处理方法开始的行号。
-
finally代码块会被编译到ireturn指令之后,并且会在冗余很多次从而减少跳转的次数。
-
异常抛出时出错源码行号,就是通常lineNumbertables将字节码出错行号映射翻译过来的。
-
localVariableTable 用于描述方法表的本地变量与源码本地变量的名称映射关系。主要功能时提高字节码的可读性,方便调试和运行时信息输出。属于可选项。
-
如果一个变量被static和final同时修饰基本数据类型,那么这个变量在编译时会生成一个ConstantValue属性用于舒适化赋值.
-
如果这个变量没有被final修饰或者不是基本数据类型择会在clinit的类初始化函数赋值
-
如果这个变量时对象成员,那么会在实例构造函数init中进行初始化。
-
ConstantValue属性只支持基础数据类型的类成员变量(即字面量变量)。
-
由于java语言编写的泛型机制在编译后所有泛型痕迹会被擦除,在1.5以前 导致从字节码无法获得与泛型相关的信息,比如在运行时我们无法通过反射获取泛型类型。知道JDK 1.6版本class字节码新增了signature属性,用于记录泛型的类型签名。从而弥补了这个缺陷,但是java的泛型依然是伪泛型。
-
由于jvm的指令码只有一个字节,所以指令集的大小非常有限,导致并非所有操作都有一条指令与之对应,因为JVM的指令需要额外的JVM解析器辅助执行来实现一个完整的操作。
-
每一段syntronized修饰的代码开,编译后会自动生成一段异常处理的指令,如果没有异常发生则会使用goto指令跳过这段异常处理的指令。异常处理指令的所执行的内容:执行monitorexit指令释放管程,并指行athrow指令抛出当前栈顶的异常对象。
-
在执行return指令的时候,会先把需要返回的变量(基本数据类型或者引用)放入栈顶,然后再执行return指令,return会把栈顶的数据作为函数的结果返回。如果函数中有finally语块,这个过程就不那么简单了,因为程序在执行return命令之前会先把栈顶结果保存到本地内存slot数组中,然后再进入到Finally的代码块执行finally代码,如果finally没有return指令,那么finally执行完了之后会跳转到原来return语句的前一行指令,把之前保存到slot数组的数据还原回栈顶,再然后在执行return 语句**【重要知识点!】**
-
被final static 初始化的基本类型静态成员在编译器就存入了常量池,所以在类加载过程中不需要进行初始化(不会出发初始化)其它的类成员会出发clinit初始化
-
主动引用触发类初始化,主动引用的情况如下:
- 随虚拟机启动的main函数会触发所在类初始化
- 类被new 或者 类的静态成员(或方法)被访问会触发类的初始化(比如类的静态成员被子类访问会触发类初始化)
- 当子类被初始化的时候,父类也会跟着初始化
- 类被反射会触发类初始化
- 如果通过invokedynamic指令解析callsite结果是一个静态方法,那么该静态方法所在的类会被初始化
-
不会触发类初始化的引用被称为被动引用。比如,只访问类A的父类静态成员,而不访问类A的静态成员不会触发类A的初始化(只会触发类A的super_class的初始化)
-
被动引用是否会触发加载和验证,在JVM规范中没有规定。
-
java数组越界访问相对C/C++安全的原因是,java将数组越界检测封装在了xastore指令和xastore指令中。
-
接口没有static{}块,但是编译器会自动帮他生成()类构造器
-
接口初始化的时候并不要求父接口也要被初始化,仅当父类接口被访问的时候才被初始化。(延迟初始化减少JVM负荷)
-
加载阶段 是 “类加载(class loading)” 过程中的一个子阶段
-
加载阶段的虚拟规范如下:
- 通过一个类的权限定名来获取此类定义的二进制流。
- 将二进制流的“静态存储结构”转化为方法区的运行时数据结构
- 在内存生成一个代表这个类的java.lang.Class对象实例(字节码对象),作为这个类在方法区的数据访问入口。
由于上面规范比较宽松,意味着虚拟机的实现存在很多灵活性。
第一点要求 没有规定二进制流的来源,只要求可以通过全限定名来获取,那么二进制流可以从zip文件中读取,比如JAR、WAR等,甚至从网络IO中、数据库获取,也可以在运行时通过反射和Proxy等动态代理技术生成。
第二点要求,只要求把二进制流转化为运行时方法区的数据结构,并没有要求数据结构的格式,因此可以按实际需要做相应的方法区格式定义。
第三点要求,没有规定java.lang.Class对象的存放位置,也就说我们在实现的时候在堆中给字节码对象分配内存,也可以在方法区给字节码对象分配内存,比如Hotspot就是这么做的。
-
数组类的加载不是由类加载器生成的,而是由虚拟机自动生成的。【重要知识点】
-
数组的组件类型(Component Type)是指数组去掉一个维度后的类型,比如int[] 的ComponentType为int,String[][]的的ComponentType为String[](字符串数组)
-
在运行时,一个类的唯一性是由类的全限定名和类加载器来一起确定的。(比如同一份字节码,如果加载他们的加载器不一样,那么这两个类在内存Class实例是完全不一样的,看起来就像两个完全不相干的类一样)
-
所有类都有且只有一个类加载器与之关联。
-
基础数据类型的类加载器是BootstrapClassloader(引导类加载器)
-
数组类的类加载器与组件类型(Component Type)的类加载器一致。
-
类的连接阶段包括了“验证”“准备“解析”
-
类的加载阶段和类的连接阶段是参差在一起,交叉进行的。
-
类的验证分为4个阶段:字节码格式验证(验证魔数与版本号等),元数据验证(根据JVM的关键词规范),字节码验证(验证方法表的code运行逻辑是否合格),符号引用验证(这一步确保解析阶段顺利进行)
-
虚拟机的验证阶段是一个比较缓慢的过程,而且是非必要的,可以使用-Xverify:none取消验证阶段
-
准备阶段是为类分配内存的阶段,将加载处理好的类运行时数据结构存放到方法区的过程,但此时的变量并未初始化,所以所有的变量值都是默认值(ConstantValue表的变量在编译阶段已经初始化好了,所以在准备阶段不是默认值)
-
解析阶段是将符号引用转化为直接引用的过程,符号引用这是一个用于索引的字面量,而直接引用则是一个可以直接访问内存的指针或者内存偏移量。
-
解析阶段根据虚拟机的实现有所不同,解析阶段可能发生在类加载器对类加载阶段时发生,也可能在快要访问变量,执行访问指令时发生解析动作。这些都由虚拟机的实现机制来决定。
-
invokedynamic指令是动态语言指令,所谓动态就是“当虚拟机执行到该指令的时候才会进行解析”,无法像其他指令一样通过标记缓存或者在类加载器加载的时候解析。
-
接口只能继承接口,类必须实现接口。运行时,JVM的加载阶段和验证阶段无法检测类是否满足这两条约束,直到解析阶段才能检测出来,一旦非法将会抛出:“IncompatibleClassChangeError”(这个异常经常在包冲突的时候出现)。
-
初始化阶段时执行类构造器()的过程。
-
解析阶段有可能发生在类初始化之后发生。
-
()类构造器时由编译器通过收集类的静态成员和stati{}静态语句块合成产生的。
-
静态语句块static{}可以初始化(赋值)定义在语句块之后静态变量,但是无法访问(读取)它们。
-
如果类没有静态成员或者静态语句块那么编译器将不会生成()方法
-
类在执行()会在执行前先执行父类的类初始化函数()。但是不会执行接口的(),仅当访问接口的静态成员时才会执行接口的()方法
-
()具有两个特点,同一个类加载器中,这个类构造函数只能被执行一次(不管执行成功、还是中途退出或者失败,都只会执行一次)。而且在执行的时候会被类加载器的同步syntronized加锁,即只能被一个线程执行。
-
每一个类加载器都拥有一个独立的“类名称空间”,如果两个类同属于同一个“类名称空间”,才有可能相等。否则,两个类即使由同一份class文件加载到虚拟机中,只要加载它们的类加载器不是同一个,就会被认为不相等。
-
对于JVM来说,只有两种类加载器,那就是“引导类加载器”,以及“其他类加载器”。从程序员的角度来说,有4种类加载器,分别是引导类加载器,扩展类加载器,应用类加载器(系统类),自定义类加载器
-
引导类加载器(BoostrapClassloader)负责加载能够被JVM识别的并且存放与<JAVA_HOME>/lib或者-Xbootclasspath指定目录下的类文件。当然除此之外,基本数据类型的数组类也是由他加载(关联在他的类名称空间下)。System.class.getClassLoader()结果为null的原因,这并不表示System这个类没有类加载器,而是它的加载器比较特殊,是BootstrapClassLoader,由于它不是Java类,因此获得它的引用肯定返回null。
-
扩展类加载器负责加载<JAVA_HOME>/lib/ext或者是JAVA.ext.dirs系统变量指定目录下的类
-
继承在字节码中是以组合和重写来实现的。比如字节码继承某一个类,则会载编译时把该类的全限定名设置到super_class属性中,如果需要访问父类的成员则更具符号引用(直接引用)查询获取父类的成员,如果需要重写父类方法,则直接在类的方法表中直接创建新的方法表即可。
-
类加载器的父子关系是通过组合来实现的。
-
双亲委派模型(Parents delegation model)规定,当请求加载类的时候,会优先层层的递交给父类加载器加载,如果父类无法加载才会层层回传请求,直到遇见有能力加载的类加载器加载为止。
-
双亲委派模型(Parents delegation model)它不是强制性实现的,而是java设计者推荐实现的.双亲委派模型有效的保证了JAVA类的唯一性,尽可能使得同一份字节码同属于同一个类加载器的类名称空间下。
-
双亲委派模型是在JDK 1.2在加入的,自定义类加载器只需要重写findclass即可实现双亲委派模型去加载类。
-
怎么遵守双亲委派模型?可能的使用JVM的三大类加载器(引导类,扩展类,系统类)来实现类的加载,如果自己定义类加载器的话,尽量不要重写loadClass()方法,而是实现findClass()方法。
-
class.forName()会马上执行()操作,而loadclass()不会,类加载默认是懒汉模式。
-
什么时候触发类的加载,JVM规范中没有约束,所有有JVM具体实现决定,就Hotspot而言,大部分的基础类会在JVM启动的时候加载,部分类实在需要用到的时候被加载。JVM规范中只规定了JVM初始化的时机(初始化触发时机),而初始化一定发生在类加载开始之后,因此类的加载一定发生在初始化触发时机之前。
-
双亲委派模型的破坏,如果类的加载没有按照双亲委派模型的规则执行,则认为是对双亲委派模型的破坏。(有时候这种破坏是必要的,但是使得类的关联变得很复杂)
历史上有3次:
第一次破坏,在JDK1.2以前,不存在findclass()方法,自定义加载器的方法就是直接重写loadclass方法。直到jdk1.2开始,把双亲委派模型的算法写入了findclass,并提供了findclass用于自动类加载方式。但是考虑到对JDK版本的向后兼容,所以并没有禁止重写loadclass方法。
第二次破坏,由于要实现SPI机制,有部份基础类实现由第三方编写,而BootstrapClassloader不知道怎么访问(加载)第三方编写的类实现,需要委托其他classloader完成(加载后的类关联到在加载该类的classloader名下,而非BootstrapClassloader),从而破坏了双亲委托原型。(如果所有基础类都由JVM团队编写的,那么所有基础类都应该由BootstrapClassloader加载,并且所有类都关联在BootstrapClassloader的类名称空间下。)而这个被BootstrapClassloader委托的classloader会被设置到Thread context classloader中,这个值默认是AppClassloader。(JDBC,JNDI就是通过SPI技术实现的)
第三次破坏,则是由于追求程序的动态性和JAVA模块化标准,比如OSGi等而不得不破坏“双亲委派模型”,从此java的类依赖树变得了更加复杂。(Dubbo采用的就是类似OSGi的模块化思想)
- SPI的实现过程,起初JVM启动后BootstrapClassloader会去加载java.util.ServiceLoader(这个类其实也是一个类加载器),虽然com.mysql.jdbc.Driver.Class接口属于基础类库中,但是它具体接口实现由第三方编写的,因此BootstrapClassloader无法加载第三方的实现类,只能选择放弃com.mysql.jdbc.Driver.Class相关的类的进一步,只能等到运行时触发java.util.ServiceLoader类的load()方法如下:
// 通过SPI加载驱动类
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next();//触发加载load
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
之后load()方法会去获取Thread context classloader的加载com.mysql.jdbc.Driver.Class接口的相关实现类
- SPI缺点:1.会把所有接口实现都加载一遍,方法区空间,2.获取某个类的实现需要遍历一遍显得很麻烦。3.可能会被重复加载。这些缺点并不是绝对的,只有当SPI的实现类很多的时候才会出现。
- 每个线程都一个线程栈,一个线程栈由许多栈帧组成,每一个帧表示一个方法表,而线程栈顶部的帧称为当前栈帧或者当前方法。还有值得注意的一点就是每一个栈帧内部还有一个叫做 **“数据栈”**的内存,这块内存是用于辅助JVM指令执行用的。
- 为了快速回收被方法局部变量引用的堆空间,有时候为局部变量赋值null.但这个方法不稳定,他会被JVM编译器优化当作无用指令给去除掉。最好的办法是利用{}语句块设置好变量的作用域边界,这是利用了线程栈帧对局部变量表的slot有复用机制,当然变量处于作用域只会就会被移除(被新的局部变量复用),从而取消了对堆空间的引用,达到回收的目的。
- 局部变量没有类似类加载的“准备阶段”,所以未赋值的局部变量没有初始值,而且该操作会被编译器识别为非法。
- jvm执行引擎被称作“基于栈的执行引擎”,这个栈指的是 “指令的操作数栈”(不是方法栈)
- 栈帧会保留着指向方法区中对于栈帧的方法代码code,这个是为了支持“动态连接”。使得方法可以等到运行时才来制定需要调用的方法。JAVA面向对象的“多态“特性就是由动态连接来实现的。动态连接通过invokevirtual指令访问。
- 静态连接,是指在类加载阶段或者解析阶段后就可以确定的直接引用调用的连接。
- 概念仅仅是说明问题的一种方式
- 重载是静态分配,重写是动态分派。为什么会产生动态分派?因为JVM指令无法识别slot中的reference的变量类型。所以在解析阶段所以只能延迟到运行时根据直接引用来访问实际类型的方法。类的重写在编译器就确定了,但是这个重写无法从slot的reference类型中感知(reference类型仅仅声明了对象在堆中的地址以及类在方法区中的地址。而编译期没有直接地址,只有符号引用,所以无从而知)
- 方法的接受者(this指针对于的堆对象)、方法的参数都被称为 “方法的宗量”(神奇的翻译),我们可以通过宗量和方法名来确定最终分派的方法代码code.
- java的异常会出现在“编译期”,“类加载阶段”,“运行时”
- 尽管invokevirtual指令具有动态连接的特性,但是他的调用者的类型必须是确定的(要么是具体类,要么是具体类的父类)。所以不属于“动态语言的特性”,直到JDK1.7引进了invokedynamic指令以及java.lang.Invoke包,才具有支持“动态语言的特性”。
- Methodhandle是java.lang.invoke包的核心
- 反射是在模仿java语言的方法调用,而Methodhandle则是在模仿JVM指令的方法调用(比如lookup方法用来定位的签名就是携带了返回值的方法表的签名)【重要知识点】
- Methodhandle是轻量级的方法访问,他在方法句柄访问性能相对比放射快很多。
- java.lang.invoke包是JAVA API的方式实现了对JVM动态语言技术的支持,而invokedynamic指令则是在字节码指令和JVM执行引擎的层面上实现的对JVM动态语言技术的支持。
- invokedynamic指令的第一个参数指CONSTANT_InvokeDynamic_Info表的符号引用,第二个参数0是填充符(忽略),CONSTANT_InvokeDynamic_Info表的第一个参数是指定需要调用引导方法表中的方法索引号,第二个参数则CONSTANT_NameAndType_Info则是指动态调用具体方法的符号引用(其中包含了方法的名称和方法的类型,即方法的JVM签名)。
- JDK版本更新通常只有4种改动:
- 编译器优化机制改良,比如装箱和拆箱。
- JAVA API的新增,这类新的API有些可以被旧版本的JVM支持,有些则不行。
- 新增JVM指令或者改动JVM字节码规则,在字节码层面做了更新,有些改动会新增API,有些改动对用户不可见。
- 修改JVM规范,这类改动对用户编程不可见。
- 类加载完成后,根据其创建的新对象所需要的内存大小就可以完全确定。