读书笔记之《深入理解Java虚拟机》不完全学习总结

时间:2022-06-03 20:57:58

写在前面:

之所以称作不完全总结,因为我其实没有完完全全地看完此书,但是涵盖了大部分重要章节;同时以下总结是我自己认为很重要知识,细枝末节处难免遗漏,还请详细参考原著。

转载请注明原文出处:http://www.cnblogs.com/qcblog/p/7704788.html 

1、java内存区

1.1、运行时数据区

读书笔记之《深入理解Java虚拟机》不完全学习总结

程序计数器是线程隔离的内存空间,并且是规范中唯一一个没有规定OutOfMemoryError的区域。

虚拟机栈也是是线程隔离的区域,方法的调用以栈帧为单位,在虚拟机栈中入栈和出栈。栈帧主要用于存储局部变量表、操作栈、动态链接和方法出口等信息。这块区域有可能会发生*Error或者OutOfMemoryError。

本地方法栈主要为native方法服务。

堆内存算是java中比较重要的数据区,是线程共享的数据区。几乎所有的对象和数组都要在堆中进行内存分配,堆也是垃圾收集器管理的主要区域。如果对堆更加细致的划分,还可划分为新生代和老年代,新生代还可划分为一个Eden空间和两个Survivor空间(Form Survivor和To Survivor)。堆可以允许物理内存空间不连续,但是逻辑上是连续的内存空间。

方法区(也称作非堆)也是线程共享的,用来存储被虚拟机加载的类信息、常量、静态变量和即时编译器编译后的代码等数据。

方法区中有一个非常重要的子区域:运行时常量池,用来存储编译生成的字面量和符号引用,直接体现为class字节码文件中的constant_pool数据项。

一个更加具体的例子,如下定义了一个最简单的几乎为空的类:

package com.demo;
public class Empty {
    static int value = 10; 
    public static void main(String[] args) {
        // TODO Auto-generated method stub
    }
}

用javap -verbose解析字节码文件,截取Constant pool段的信息,这其实就是常量池的静态文件展现形式。

读书笔记之《深入理解Java虚拟机》不完全学习总结

从上面会看到常量池中总共24项(从1开始计数),其中有一些很熟悉的字面量和符号引用:java/lang/Object,[Ljava/lang/String;:String类型的数组描述符,I:整型描述符,<clinit>:类初始化方法,<init>:构造器,this,main,Code:属性表的Code属性,方法体的代码编译后存档在该属性下面,等等,这些都将会在字节码文件的其他数据项中被引用到。

1.2、对象访问

对象访问的两种方式:使用句柄和使用直接指针。

Sun HotSpot使用直接指针的方式访问对象。

1.3、几个重要的内存参数

-Xmx Java Heap最大值,默认值为物理内存的1/4,最佳设值应该视物理内存大小及计算机内其他内存开销而定;
-Xms Java Heap初始值,Server端JVM最好将-Xms和-Xmx设为相同值,开发测试机JVM可以保留默认值;
-Xmn Java Heap Young区大小,不熟悉最好保留默认值;
-Xss 每个线程的Stack大小,不熟悉最好保留默认值;

-XX:PermSize:JVM初始分配的非堆内存
-XX:MaxPermSize:JVM最大允许分配的非堆内存,按需分配

2、自动内存管理

java的自动内存管理实际上包含了两方面的内容:给对象分配内存和回收分配给对象的内存(垃圾收集)。

垃圾收集和内存自动分配并非源自java,实际上Lisp是第一门使用该技术的语言。

2.1、垃圾收集

2.1.1、对象存活的判定

判断对象时否存活的算法通常有两种:引用计数算法和根搜索算法。

引用计数算法

给每个对象设置一个引用计数器,每当有一个地方引用该对象时,计数器的值就加1,当引用失效时,计数器的值就减1,当计数器的值为0时,代表该对象不再被使用应该被回收掉。

这种算法实现简单,通常情况下判断效率也是很高的,但是不能判断循环引用的情况。例如对象A的成员变量引用了对象B,同时对象B的成员变量又引用了对象A,A和B对象相互构成循环引用,虽然A和B对象的引用计数器不为0,但他们有可能已经是无用的对象,此时引用计数器算法在这种情况下的判定存在盲区,算法是失效的。java虚拟机也没有采用这种算法。

根搜索算法

通过一系列称为“GC Root”对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Root之间没有任何引用链相连时(从图论角度就是GC Root到该对象不可达),则该对象不可用应该被回收。

那么哪些对象可以被用来作为GC Root呢?如下几种可以用来充当GC Root对象:

1)虚拟机栈(本地变量表)中引用的对象;

2)方法区中的静态变量引用的对象;

3)方法区中常量引用的对象;

4)本地方法栈(native方法)中引用的对象。

引用扩充:

JDK1.2后将引用的概念扩充,形成了强引用、软引用、弱引用、虚引用四种不同的引用,引用强度依次减弱。

值得注意的是:

一个对象真正死亡实际上需要经历至少两次标记,在经历根搜索算法后发现该对象没有与GC Root相连,此时发生第一次标记,然后会进行一个“是否需要执行finalize()”的判定,如果被判定为需要执行finalize()方法,稍后由虚拟机调用finalize(),此时会被第二次标记。

对象的finalize()方法只能被系统自动调用一次,finalize()也是对象逃离死亡的最后一次机会。

2.1.2、垃圾收集算法

主要的垃圾收集算法有四个:标记-清除算法、复制算法、标记-整理算法、分代回收算法。

标记-清除算法:

标记-清除算法是比较基础的收集算法,分为标记和清除两个阶段,但是有两个主要的缺点:效率不高,容易产生内存碎片。

复制算法:

复制算法可以解决效率的问题,基本的思想是让内存容量的一半用来相互充当“备胎”,当其中一半用完,就将存活的对象直接复制到另一半空间中,把原来的那一半空间全部清空,再次充当“备胎”作用。这种机制同时不用考虑内存碎片的影响。

但是事情还不止于此。

实际上,实际的情况是按照8:1:1来划分,8指Eden空间,两个1指相同大小的Survivor空间(From Survivor和To Survivor),每次使用Eden空间和其中一块Survivor空间,当进行垃圾回收时,将正在使用的Eden和Survivor空间中存活的对象复制到另外一块空闲的Survivor空间,同时清空掉刚才使用的Eden和Survivor空间,当然这里还有内存担保的机制。基于这种“复制”对象的机制,不难看出,当对象的成活率比较低的时候,这种算法的成本很小,效果很好。

标记-整理算法:

标记-整理算法主要是针对对象成活率较高,只有少数对象被回收的情况,这种情况没有必要进行全部对象的移动,只需要释放少数被回收对象的空间,同时对剩余对象进行空间调整以至于不会出现严重的内存碎片的情况。

分代回收算法:

分代回收算法将根据对象存活周期的长短,将堆分为新生代和老年代,并采用不同的算法。

新生代指对象存活期短的区域,垃圾收集会有大量对象被回收,适合采用复制收集算法;老年代指对象存活期长的区域,垃圾收集会有大量对象存活,适合采用标记-整理算法。

Minnor GC和Full GC的不同:

新生代GC(Minnor GC):发生在新生代的垃圾收集动作,对象存活期短,Minnor GC非常频繁,速度也比较快。

老年代GC(Major GC/Full GC):发生在老年代的垃圾收集动作,对象存活期长,Major GC比Minnor GC慢很多(10倍以上)。

2.2、内存分配

内存分配分配的原则:

1、对象优先在Eden空间分配。

2、大对象直接进入老年代(连续内存空间的对象,典型的长字符串或者数组)。

3、长期存活的对象将进入老年代。

对象在Survivor空间没熬过依次Minnor GC,其对象年龄计数器(Age)加1,累计增加到默认值15时,晋升到老年代。

 4、但实际上,只要在Survivor空间中相同年龄的所有对象的总和大于Survivor空间的一般,则年龄大于等于该年龄的对象就会进入老年代,无需等到年龄的一个阈值。

 内存担保的作用在于新生代垃圾收集收效甚微,有大量的对象存活,此时另外一个充当“备胎”的Survivor空间容纳不下,则会将容纳不下的对象直接进入老年代。但是这里还有一个问题要思考:此时老年代是否能够容纳得下这里从新生代过来的对象呢?(注意:在没有对新生代进行Minnor GC之前,无从知道究竟有多少对象会存活)

如果容纳不下,那么还得对老年代进行一次Full GC腾出空间呢。所以这里只能取一个之前每一次回收晋升到老年代的对象容量的平均值作为一个经验值,来和老年代此时剩余的空间做比较,以此来判断是否需要做Full GC。但即使是这样,在概率的意义下任然不可避免担保失败的情况发生。

3、class文件结构

class文件以字节为单位,以类似C语言的结构体的伪结构来组织数据,这种伪结构只有两种数据类型:

1)无符号数(u1,u2,u4,u8,属于数据基本数据类型)

2)表(属于复合数据类型)

基本数据数据类型组合形成表,表与表之间可嵌套,可形成多维表,实际上可以将整个class文件看成一张表,而这张表中层层嵌套了其他不同的表。

class总体来看由如下内容组成:

读书笔记之《深入理解Java虚拟机》不完全学习总结

这部分的内容虽然枯燥,阅读需要静心和耐心,但是当我们需要用javap去解析class文件的字节码指令探索语言更深一层的原理时,这部分知识无疑是必须的。

其中我认为最重要的就是方法表和常量池,常量池之所以重要是因为它是class文件中与其他数据项(主要是字段表、方法表和属性表)关联、交互最多的数据类型。方法表(包含其中的属性表)之所以重要是因为在分析字节码文件时通常更加关注java源文件中的方法体被编译后字节码展现,这部分更准确的说是在方法表的内嵌属性表的“Code”属性中。

常量池:

常量池其实算是class文件中比较复杂的数据项,因为常量池可以存放11项不同的常量,而这11项不同的常量实际上又是复合数据类型(表结构数据,以_info结尾),它们都有各自的结构(实际上它们的结构都比较相似)。总结来说,常量池本身是一张表,表中的每一项也是一张表,因此可以认为常量池是二维表结构。

需要总结几点:

1、常量池从1开始计数,第0项有特殊的意义。

Constant pool:
#1 = Class #2 // com/demo/TestDispatch
#2 = Utf8 com/demo/TestDispatch

2、常量池主要存放字面量(Literal)和符号引用(Symolic References)。

符号引用主要包括:

1)类和接口的全限定名(Fully Qualified Name
2)字段的名称和描述符(Descriptor
3)方法的名称和描述符

字段表:

字段表(field_info)中有三个概念需要注意:“全限定名”、“简单名称”和“描述符”

全限定名好理解,简单名称就是去掉类型和参数修饰的方法或者字段名称。

而描述符就比较复杂一点,主要需要注意8中基本数据类型的描述符(B,C,D,F,I,J,S,Z),void的描述符(V),对象类型描述符(L),数组类型描述符([)以及方法的描述符(先描述参数列表,放在()中,后描述返回值类型),这些是看懂字节码文件的前提。

对于属性表(attribute_info),重点关注的就是Code属性,涉及到方法体的字节码部分,LineNumberTable,LocalVariableTable两个属性还好。

4、类加载过程

类的生命周期中有7个阶段:加载、验证、准备、解析、初始化、使用、卸载。

虚拟机规范中没有规定类加载的时机,但是规定了对类的主动引用的4种场景,这四种场景下,类要求被初始化:

1) 遇到new、getstatic、putstatic或invokestatic这四条字节码指令(注意,newarray指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,比如,new String[]只会直接触发String[]类的初始化,也就是触发对类[Ljava.lang.String的初始化,而直接不会触发String类的初始化)时,如果类没有进行过初始化,则需要先对其进行初始化。生成这四条指令的最常见的Java代码场景是:

• 使用new关键字实例化对象的时候;
• 读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;
• 调用一个类的静态方法的时候。

2) 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3) 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4) 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

其余的场景称为被动引用,不会对类进行初始化。

4.1、类加载的过程

加载阶段的工作主要包括:

1) 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);
2) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
3) 在内存中(对于HotSpot虚拟就而言就是方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;

验证阶段主要的工作包括:文件格式验证、元数据验证、字节码验证和符号引用验证。

准备阶段:该阶段需要特别注意,该阶段为类变量分配内存并赋零值(此零值指各种具体类型的默认初始值),类变量在这个阶段第一次被赋值。同时,final修饰的类变量直接赋予程序设定的值而不是零值。

解析阶段的工作:将常量池中的符号引用替换为直接引用。

为什么会存在这样一个解析过程呢?

因为由java源文件编译成字节码过程的不涉及通常意义下的“编译”过程中链接,因此编译后的字节码文件的常量池中符号引用与虚拟机的内存布局还没有发生映射关联,引用的目标不一定加载到了内存中,但是字节码指令的运行必须要与有这样的关联,也就需要这样一个转换过程。

解析主要针对类或者接口(都为Class),字段(Fieldref)、类方法(Methodref)和接口方法四类符号引用进行解析。

初始化阶段:

初始化阶段是类加载的最后一个阶段,该阶段是真正按照程序代码的意图为类变量设定初始值(即开始真正执行代码赋初值,也即执行构造器<clinit>)。

该阶段应该特别关注<clint>的生成原理(收集类变量和静态语句块的过程)以及与类构造器(<init>构造器)的区别。

在通过javap解析字节码文件中可能不会真正看到<clint>构造器的调用,但实际上在任何一个class文件的常量池中(当然需要定义类成员或者静态代码块)都是可以找到<clint>和<init>的符号引用。

4.2、类加载器

不同于源代码中,在虚拟机有加载器的存在,此时由加载器实例和类的权限定名共同来唯一确定一个类。

从是否独立于虚拟机的角度:加载器可分为启动加载器(是虚拟机的一部分)和其他加载器(独立于虚拟机之外)。

更细的来划分,有三类加载器:

1)启动类加载器(BootstrapLoader):是用本地代码实现的类装入器,它负责将 <Java_Runtime_Home>/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
2)扩展类加载器(ExtClassLoader):是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将< Java_Runtime_Home >/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
3)系统类加载器或者应用类类加载器(AppClassLoader):是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。

他们之间存在父子关系,从代码的角度来讲,其父子关系是从parent实例属性来体现的。

在抽象类java.lang.ClassLoader源码中明确定义了一个parent实例属性,

    private ClassLoader parent;

Launcher$ExtClassLoader和Launcher$AppClassLoader均继承了URLClassLoader,URLClassLoader又继承了SecureClassLoader,而SecureClassLoader是ClassLoader的直接实现子类。

从抽象类java.lang.ClassLoader的loadClass(String name, boolean resolve)方法源码可以很容易的窥探到双亲委派模型的运作过程:

        protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{
                // First, check if the class has already been loaded
                Class c = findLoadedClass(name);
                if (c == null) {//如果该类未被加载
                    try {
                      if (parent != null) {//其父加载器不为BootstrapLoader
                          c = parent.loadClass(name, false);
                      } else {//其父加载器为BootstrapLoader
                          c = findBootstrapClass0(name);
                      }
                    } catch (ClassNotFoundException e) {//父加载均无法加载,才由自己加载
                        // If still not found, then invoke findClass in order
                        // to find the class.
                        c = findClass(name);
                    }
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
        }

双亲委派模型的一个直接的好处就是保证了java类型体系中确定的层次关系。更具体的讲,例如Object.class字节码文件存在于<Java_Home>/lib/的rt.jar包中,那么遵循双亲委派的父加载器优先的原则,每次加载Object这个类的都可以确定必是由BootstrapLoader加载,一方面,其他地方的Object.class不会被BootstrapLoader加载到,另一方面,正常情况下另外两个类加载器也没有机会加载这个Object.class,从而保证基础类库加载的状态确定性,保证了在其基础上开发的java程序运行的稳定性。

5、执行引擎

5.1、方法调用

注:java的编译过程不涉及通常意义下的编译阶段的链接;方法的调用不同于方法的执行,方法调用的目的在于确定某一方法的某个具体版本。

java虚拟机提供了四条方法调用的指令:

1、invokestatic:调用静态方法

2、invokespecial:调用构造器方法<init>,私有方法和父类方法

      20: invokespecial #49                 // Method "<init>":()V

3、invokevirtual:调用所有的虚方法

4、invokeinterface:调用接口方法,会在运行时在确定一实现此接口的对象。

能被invokestatic和invokespecial指令调用的方法(类方法、构造器、私有方法和父类方法)以及final修饰的方法都称为非虚方法,其他的方法都成为虚方法。

非虚方法都能在类加载的解析阶段完成方法的版本确定工作,符号引用也会被解析成直接引用,这个过程的方法调用称为解析调用。

5.2、静态分派和动态分派

解析调用一定是一个静态的过程,而分派调用可能是静态的也可能是动态的,还可以从另一个角度分为单分派和多分派。两两可组合形成静态单分派、静态多分派,动态单分派和动态多分派四种情形。

静态分派调用典型应用就是方法的重载。

由于java中子类的实例可以复制给父类的变量,因此就有可能存在一个变量的静态类型和实际类型不一致的情况。

例如(当然这里的前提是Man extends Human):

Human humanA = new Man();

humanA的静态类型是Human,而实际类型是Man。

依赖静态类型来确定方法执行版本分派动作称为静态分派。静态分派发生在编译阶段,编译器正是根据参数的静态类型而不是实际类型来确定方法的具体版本,这一点可以通过对查看编译后的字节码的方法调用指令得到验证。

测试代码:

package com.demo;
public class TestDispatch { static abstract class Human{} static class Man extends Human{} static class Woman extends Human{} public void say(Human huamn){ System.out.println("human say"); } public void say(Man man){ System.out.println("man say"); } public void say(Woman man){ System.out.println("woman say"); } public static void main(String[] args) { // TODO Auto-generated method stub Human humanA = new Man(); Human humanB = new Woman(); TestDispatch td = new TestDispatch(); td.say(humanA); td.say(humanB); } }

截选如上测试代码的main方法部分的字节码指令:

public static void main(java.lang.String[]);
  Code:
     0: new           #43                 // class com/demo/TestDispatch$Man
     3: dup 4: invokespecial #45 // Method com/demo/TestDispatch$Man."<init>":()V 7: astore_1 8: new #46 // class com/demo/TestDispatch$Woman 11: dup 12: invokespecial #48 // Method com/demo/TestDispatch$Woman."<init>":()V 15: astore_2 16: new #1 // class com/demo/TestDispatch 19: dup 20: invokespecial #49 // Method "<init>":()V 23: astore_3 24: aload_3 25: aload_1 26: invokevirtual #50 // Method say:(Lcom/demo/TestDispatch$Human;)V 29: aload_3 30: aload_2 31: invokevirtual #50 // Method say:(Lcom/demo/TestDispatch$Human;)V 34: return

可以看到,第26行和第31行,invokevirtual指令调用参数都是常量池的第50项常量,注释表明该常量实际上是TestDispatch$Human.say(Human human)的符号引用.

动态分派则揭示了方法重写(或者覆盖)的本质。

invokevirtual指令运行时解析过程(多态查找过程):

1、找到操作数栈顶的第一个元素指向的对象的实际类型,记作C;

2、如果类型C中找到与常量中描述符合简单名称相符合的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过结束;不通过则返回java.IllegalAccessError异常;

3、否则,按照继承关系从下往上依次对C的各个父类进行第2步搜索和验证;

4、如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

Java虚拟机的指令是基于栈的指令,但是某些指令还会带参数(如invokevirtual,invokespecial 等),与基于寄存器的指令集相比,基于栈的指令集更方便移植,但是执行速度稍慢,同时指令数量会更加多一些。

6、语法糖

java的中常用的语法糖主要是泛型,变长参数,自动拆装箱,循环遍历(foreach)等等。

因此java的泛型与C#的泛型原理实际上有本质的区别,java的泛型只存在于源代码中,经过编译之后,泛型信息会被擦除,在字节码中被转换成原生类型,ArrayList<Integer>和ArrayList<String>编译后被转换成同一种类型ArrayList,因此java的泛型是语法糖,是一种伪泛型。

注意:泛型不一定都是语法糖实现,如C#的泛型就是直接有CLR支持的。

但是泛型擦除也带了一些比较奇怪的现象:

    public static int test(ArrayList<String> list1){
        System.out.println("list1");
        return 1;
    }
    public static float test(ArrayList<Integer> list2){
        System.out.println("list2");
        return 1.0f;
    }

以上两个同名的方法可以正常编译和执行。

关于自动拆、装箱,foreach遍历和可变长参数的语法糖,以下是一个很经典例子:

package com.demo;

import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

public class TestSSugar {
    public static void main(String[] args) {
        // TODO Auto-generated method stub
        List<Integer> list = Arrays.asList(1,2,3,4);
        int sum = 0;
        for(int i:list){
            sum += i;
        }
        /*以上代码等价于如下的基础语法结构的代码*/
//        List<Integer> list = Arrays.asList(new Integer[]{//可变长参数经过解语法糖,实际上是数组
//                Integer.valueOf(1),//自动装箱实际上是调用了valueOf()方法
//                Integer.valueOf(2),
//                Integer.valueOf(3),
//                Integer.valueOf(4),});
//        int sum = 0;
//        for(Iterator localIterator = list.iterator();localIterator.hasNext();){//foreach遍历经过解语法糖实际上是调用Iterator接口
//            int i = (Integer)localIterator.next();
//            sum += i;
//        }
        System.out.println(sum);
    }
}

完结~~~