深入理解Java虚拟机 笔记

基于《深入理解Java虚拟机第二版》周志明  一书整理的笔记

注:使用Sublime Text编辑的,博客显示效果并不理想,可粘贴到本地使用Sublime Text打开阅读。

 

运行时数据区:
    程序计数器(Program Counter Register):当前线程执行字节码的行号指示器,通过修改指示器位置来取下一条指令。
        如果执行的是Native方法,则计数器指为Undefined

    Java虚拟机栈(Java Virtual Machine Stacks):基本单位,栈帧 ,每个方法在执行的同时就有一个栈帧入栈。栈帧由1局部变量表2操作数栈3动态链接4方法出口等
        局部变量表可能存放编译期可知的各种基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)
        局部变量表的内存空间在编译期间完成分配

    本地方法栈(Native Method Stacks):类似于虚拟机栈,不过虚拟机栈为Java方法服务,本地方法栈为Native方法服务

    Java堆(Java Heap):线程共享,用于存放对象实例。所有对象实例及数组都要在堆上分配,垃圾收集器管理的主要区域。

    方法区(Method Area):线程共享,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。堆的一个逻辑部分。
        
    运行时常量池(Runtime Constant Pool):方法区的一部分,Class文件的常量池信息用于存放编译期生成的各种字面量和符号引用,这些信息在类加载后将在方法区的运行时常量池中存储,运行期间也可能将新的常量放入池中,如String.intern()。
        String.intern()是Native方法,作用是如果字符串常量池中已经包含等于该字符串的常量,则直接返回对该String的引用,否则先将该String加入常量池。

    直接内存(Direct Memory):不是虚拟机运行时数据区的一部分,NIO使用Native函数库直接分类堆外内存。


HotSpot虚拟机中
对象的创建:语言层面上通过new关键字完成
    1)虚拟机遇到new指令,检查常量池中是否有被new类的符号引用,检查这个符号引用是否已被加载、解析和初始化过,若没有则先进行类加载
    2)类加载通过后,虚拟机为新生成的对象分配内存空间(类加载完成对象所需的内存空间就以确定),分配内存的方式可分为两种情况
        1Java堆内存绝对规整,则只需将区分已用内存和未分配内存的指示器向后挪动所需分配内存的大小即可,这种方式称为指针碰撞。(使用Serial、ParNew等带Compact过程的收集器时使用)
        2Java堆内存不规整,空闲内存和已用内存交替存在,此时虚拟机要维护一个列表来记录那些内存是可用的,分配时找个一个足够大的内存空间分配给新对象,这种方法称为空闲列表法。(CMS这种基于Mark-Sweep算法的收集器时使用)
    内存分配时还考虑线程安全。(可能出现正在给A分配内存,指针还没来得及修改对象B又使用了原来的指针。)解决办法1:同步处理,采用CAS加重试。解决办法2:把内存分配动作按照线程划分在不同空间中,为每个线程在Java堆相预先分配一块小内存,称为本地线程分配缓冲区(TLAB),线程要分配内存时,现在线程的TLAB上分配。
    3)内存分配完后,虚拟机将分配的内存空间初始化为零值(保证了Java对象不赋初值就可以直接使用)
    4)设置对象头信息,(包括如何找到此类的元数据信息,对象的哈希码,对象的GC分代年龄等),以及是否启用偏向锁
    5)此时虚拟机认为一个对象就产生了,此时对象的<init>方法还没执行。

对象的内存布局:对象头,实例数据,对齐填充。
    对象头:分为两部分。
        第一部分存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等
        第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例。
        (如果该对象是Java数组那么对象头还应有一块记录数组长度的数据)
    实例数据:程序代码中定义的各种类型的字段内容。(包括父类继承的和子类自己定义的)
    对齐填充:确保对象大小为8字节整数倍
(元数据解释: http://www.ruanyifeng.com/blog/2007/03/metadata.html)

对象的访问定位:Java程序通过栈上的reference数据操作堆上的具体数据。(虚拟机规范并未定义reference如何定位、访问堆上数据)目前主流的访问方式有:使用句柄和直接指针。
    使用句柄:Java堆上会划分出一块内存来存储句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据和类型数据的各自具体地址信息。(49页图2-2)
        好处:reference中有稳定的句柄地址 。对象被移动时只会句柄中的实例数据指针,不必改变reference中句柄地址。
    直接指针访问:reference中存储的直接是对象地址。(Java对象布局中则要考虑放置访问对象类型数据的地址)(49页图2-3)
        好处:速度更快,因为节省了一次指针定位的时间。


第三章、垃圾收集器和内存分配策略
程序计数器、虚拟机栈、本地方法栈这些内存都是随线程生随线程灭,在运行前其所占用内存就已确定下来。不需要过多考虑回收问题。
Java堆和方法区则无法确定,我们只有在程序运行期间在知道该创建哪些对象,这部分的内存分配都是动态的。

如何判断对象已‘死’?
    1引用计数算法:给对象添加一个引用计数器,增加引用则加1,引用失效则减1。
        特点:实现简单,但很难解决对象循环引用的问题。 (A.obj1=B,B.obj2=A)
    2可达性分析算法:通过一系列称为"GC Roots"的对象为起点,从这些节点向下搜索,走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有引用链时,则表明该对象不可用。
        GC Roots对象可以有下列几种:
            1)虚拟机栈(栈帧的本地变量表)引用的对象
            2)方法区中类静态属性引用的对象
            3)方法区中常量引用的对象
            4)本地方法栈中JNI引用的对象

Java引用
    1.2以前,只存在引用和未引用两种状态。但是有需求:一些对象在,内存足够时能够保存在内存中,当GC进行垃圾收集后内存仍然紧张则将其回收。
    1.2以后,分为四种引用:强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)。引用强度递减。
        强引用:只要强引用在,对象永远不会被回收.
        软引用:描述还有用但并非必需的对象,系统将要发生内存溢出前进行第二次内存回收就会回收这种对象,如果还没有足够内存则会发生内存溢出。
        弱引用:也是描述非必需对象,强度比软引用更弱。弱引用关联的对象只能存活到下一次垃圾收集前。
        虚引用:最弱的引用关系,无法通过虚引用获取对象实例,引入目的是在对象被回收时收到一个系统通知。


回收前的两次标记
    即使可达性分析得出不可达的对象,也不是立即回收,在此之前至少会进行两次标记:如果一个对象没有和GC Roots有引用链,则进行第一次标记和筛选,筛选的条件是:该对象是否有必要执行finalize()方法。不必要执行的两种情况:对象没有覆盖finalize()方法,虚拟机已经调用过该对象的finalize()方法。在对象执行finalinze()方法时如果该对象再次与引用链建立关联,则可以逃脱死亡。  (建议绝不使用finalize()方法)

回收方法区:    方法区(HotSpot中永久代)主要回收两部分:废弃常量和无用的类
    废弃常量回收:当该常量没有被引用时回收。
    无用的类回收:同时满足3个条件:
        1)该类的所有实例已被回收
        2)加载该类的ClassLoader已被回收。
        3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法
    标记清除算法(Mark-Sweep):先标记所有要回收的对象,标记完成后统一回收被标记的对象,
        特点:效率问题,标记和清除效率不高。空间问题,标记清除产生大量不连续的内存碎片,继续给较大对象分配内存时可能导致继续触发GC。

    复制算法(Copying):将内存按容量分为大小相等的两块,每次只使用其中一块,当这一块用完,将存活的对象复制到另一块,这样每次都对整个半区进行内存回收,就不用考虑内存碎片的问题,只需移动堆顶指针顺序分配内存即可。
        特点:实现简单,运行高效,但是将内存缩小为原来的一半,代价太大。

    标记整理算法(Mark-Compact):标记部分和标记清楚算法一样,在标记完成后不直接对可回收对象进行清理,而是将存活对象移动到内存空间的一端,直接清除其他的内存。
        特点:相比复制算法,更适合对象存活率高的场景(如老年代),相比标记清除算法又没有内存碎片问题。

    分代收集算法(Generational Collection):将Java堆分为新生代和老年代,新生代中存活率较低,可以采用复制算法,老年代中存活率较高,可采用标记清除算法或标记整理算法。

垃圾收集器
新生代(Youth generation)收集器(采用复制算法)
    Serial收集器:单线程收集器,并且在执行垃圾收集时,要暂停其他所有工作线程,直到收集完成。
        特点:简单高效,没有线程交互开销,但停顿时间较长。

    ParNew收集器:多线程版本的Serial收集器,使用多个GC线程进行垃圾收集。

    Parallel Scavenge收集器:并行的多线程收集器。目标是达到一个可控制的吞吐量(CPU运行用户代码时间/(CPU运行用户代码时间+垃圾收集时间)),可以使用自适应调节策略。
        GC停顿时间缩短以牺牲吞吐量和新生代内存为代价,可能会导致GC更频繁。
老年代(Tenured generation)收集器
    Serial Old收集器:老年代版本Serial收集器,单线程收集器,采用标记-整理算法。

    Parallel Old收集器:老年代版本的Parallel Scavenge收集器,和Parallel Scavenge配合,在注重吞吐量以及CPU敏感的场景发挥良好。

    CMS(Concurrent Mark Sweep)收集器:一种以获取最短停顿回收时间为目标的收集器    ,采用标记清除算法。
        其运作分为四个步骤:
            1初始标记:Stop The World,标记GC Roots直接关联的对象。
            2并发标记:沿着GC Roots的引用链往下标记。
            3重新标记:Stop The World,修正并发标记期间由于用户程序运行导致的标记变动的对象
            4并发清除
        特点:停顿时间短,对CPU资源敏感,无法处理浮动垃圾,容易产生内存碎片。
            (浮动垃圾:CMS并发清理阶段,用户线程产生的垃圾,只能等到下一次GC再清理)
G1收集器:特点:并行和并发,分代收集,空间整合(整体来看像标记整理算法,局部像复制算法),可预测的停顿,横跨新生代和老年代。
        其运作分为四个步骤:1初始标记2并发标记3最终标记4筛选回收

内存分配与回收策略:对象的分配往大了讲是在堆上分配,细节来说对象主要在新生代Eden区上分配(如果开启了本地线程缓冲,则按线程优先在TLAB上分配),少数会分配在老年代。
    对象优先在Eden区分配:大多数时候对象优先分配在新生代Eden区,当Eden区没有足够空间时,会触发一次Minor GC.
    大对象直接进入老年代:避免Eden区和Survivor区发生大量内存复制
    长期存活的对象将进入老年代:虚拟机给每个对象设置了对象年龄(Age)计数器,如果对象在Eden区出生,并且经过一次Minor GC仍然存活,并且能够被Survivor区容纳的话,就进入Survivor区,并且Age设置为1,对象在Survivor区每熬过一次Minor GC,Age都会+1,Age增长到一定值时则会进入老年代。(除此判定法还有动态对象年龄判定)

Minor GC具体过程:https://blog.csdn.net/u010385090/article/details/101955659
Minor GC ,Full GC 触发条件
Minor GC触发条件:当Eden区满时,触发Minor GC。
Full GC触发条件:
    (1)调用System.gc时,系统建议执行Full GC,但是不必然执行
    (2)老年代空间不足
     (3)方法区空间不足
    (4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    (5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小


第六章、类文件结构
Class类文件的结构:一组8字节为基本单位的二进制流。有两种数据类型,无符号数和表
    魔数与Class文件版本:Class文件的头4个字节为魔数(magic),其唯一作用是来确实此文件是否是一个能被虚拟机接受的Class文件,第5~6个字节代表次版本号(minor _version),第7~8个代表主版本号(major_version)

    常量池(constant_pool):常量池前有u2的常量池容量计数器(constant_pool_count)。常量数量=constant_pool_count-1。常量池主要存放两大类变量:字面量和符号引用。
        字面量:如文本字符串,声明为final的常量值等(还有疑惑)。
        符号引用:1类和接口的全限定名2字段的名称和描述符3方法的名称和描述符。

    未完。。。


第七章、虚拟机类加载机制
    概述:虚拟机把描述类的Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

    生命周期:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。(验证、准备、解析统称连接(Linking))

    类加载时机:加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的。加载过程必须这样按部就班的‘开始’。

    虚拟机规范并未说明什么情况开始类加载的第一个阶段,但对类初始化只能严格在以下5种情况下进行(主动引用):
        1.遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有初始化,则要先进行初始化。分别对应的场景:使用new实例化对象、获得类静态字段(被final修饰、已在编译期把结果放入常量池除外,后边修改也是如此),修改类静态字段、调用一个类的静态方法。
        2.使用java.lang.reflect包对类进行反射调用时,如果类没有初始化,则先要进行初始化。
        3.初始化一个类的时候如果发现其父类还没有进行初始化,则先要对其父类进行初始化。
        4.当虚拟机启动时,需要指定一个执行的主类(包含main()的类),虚拟机会先初始化这个类。
        5.当使用虚拟机动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄时,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。

        注:接口初始化时第三条有所不同,一个接口初始化时,并不要求其父接口都完成了初始化,只有真正用到父接口(如引用父接口定义的常量)才会进行父接口初始化。

    被动引用的例子:
        1.通过子类引用父类的静态字段,不会导致子类被初始化。    Sub.superValue
        2.通过数组定义来引用类,不会触发此类的初始化。  A[] a = new A[10];
        3.常量在编译阶段会存入调用类的常量池,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。 System.out.print(A.HELLO);

    加载:完成三件事。
        1.通过一个类的全限定名来获取此类的二进制字节流。
        2.将这个字节流所代表的静态存储结构转化为运行时数据结构。
        3.在内存中生成这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
    开发人员可以通过自定义类加载器(重写一个类加载器的loadClass()方法),来控制字节流的获取方式。
    数组类加载:数组类本身不用类加载器加载,而是Java虚拟机直接创建,但是数组类元素(指数组去掉所有维度的类型)仍需要类加载器加载。数组类创建需遵循的规则:
        1.如果数组的组件类型(Component Type指数组去掉一个维度的类型)是引用类型,就递归的用前边的加载过程加载这个组件类型,该数组将在加载该组件类型的类加载器的类名称空间上被标志。
        2.如果该数组的组件类型不是引用类型(如int[]),则会把该数组标记为与引导类加载器相关联。
        3.数组的可见性与它的组件类型的可见性一致,如果组件不是引用类型,则数组的可见性默认是public。

    验证:是连接的第一步(可以没有这步骤)。作用是:确保Class文件中的字节流所包含的信息符合虚拟机规范,并不会危害虚拟机的安全。分为四个阶段:
        1.文件格式验证:验证字节流是否符合Class文件的规范,并且能被当前版本虚拟机处理。该阶段验证基于二进制字节流进行,只有通过了这个阶段验证,二进制字节流才会进入方法区存储,后边三个阶段的验证都是基于方法区的存储结构进行的。
        2.源数据验证:对字节码描述的信息进行语义分析,保证其符合Java语言规范
        3.字节码验证:通过数据流和控制流的分析,确保程序逻辑是合法、符合逻辑的。
        4.符号引用验证: 对类自身意外的信息进行匹配性校验。

    准备:正式为类变量分配内存并设置类变量初始值(一般是0值)的阶段,在方法区进行分配(即不会分配实例变量,只分配类变量(static变量))。

    解析:将符号引用换为直接引用的过程。
        符号引用:一组符号用于描述所引用的目标,符号引用可以是任何字面量,常见的如:com.test.ClassA。引用的目标不一定已加载到内存,与虚拟机的内存布局无关。
        直接引用:可以是直接指到目标的指针、相对偏移量或一个能够间接定位到目标的句柄。与虚拟机的内存布局有关,引用的目标必须已加载到内存中。

    初始化:执行类构造器<clinit>()的过程。在准备阶段已经赋过一次初始值,在初始化阶段按照程序员通过程序制定的主观计划去初始化类变量和其他资源。
        关于<clinit>():
            1.<clinit>()由编译器自动收集类中的所有类变量和静态语句块(static{}块)的语句合并而成的,收集的顺序是按照语句在源程序中的顺序决定的。静态语句块只能访问定义在其之前的变量,可以赋值但不能访问定义在其之后的变量。(225页代码清单7-5)
            2.<clinit>()和<init>()的区别(<init>()即类的构造函数):<clinit>()不需要显式调用父类的构造器,虚拟机会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。(可以推导第一个执行的<clinit>()方法是属于java.lang.Object的)(同时也意味着父类的静态语句块执行早于子类)
            3.类或接口可以没有<clinit>(),如果没有静态语句块和对类变量的赋值操作,则虚拟机可以不生成<clinit>()方法
            4.接口的<clinit>()不需要先执行父接口的<clinit>(),只有当父接口中定义的变量被使用时,才会调用父接口的<clinit>()    。
            5.虚拟机会保证<clinit>()在多线程环境会被正确加锁,同步。会采用阻塞方式,如果一个类的<clinit>()耗时很长,会导致其他进程阻塞。

    类加载器:根据一个类的全限定名去获取描述这个类的二进制字节流的代码模块(加载阶段进行)。
        类与类加载器:比较两个类是否相等,只有在这两个类属于同一个类加载器才有意义,每个类加载器都拥有一个独立的类名称空间。
        双亲委派模型:(231页图7-2)
            从虚拟机角度类加载器分为两类:启动类加载器(Bootstrap ClassLoader)(C++实现)和其它类加载器(Java实现,继承自java.lang.ClassLader)。
            从Java开发人员角度分类类加载器可以分为三类:
                1.启动类加载器(Bootstrap ClassLoader):将<JAVA_HOME>/lib目录下的或者被-Xbootclasspath指定的,并且是被虚拟机识别(仅按文件名识别,如rt.jar名字不符合,就不会被识别)的类库加载到虚拟机内存中。
                2.扩展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>/lib/ext下或者被java.ext.dirs指定的目录的所有类库,开发者可以直接使用扩展类加载器。
                3.应用程序类加载器。(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径(ClassPath)上指定的类库。ClassLoader的getSystemClassLoader()的方法返回的即是此类加载器,程序默认的类加载器,开发和可以直接使用应用程序类加载器。
            双亲委派模型:启动类加载器<-扩展类加载器<-应用程序类加载器<-自定义类加载器
                除了顶层的启动类加载器,其它类加载器均有父类加载器,这里的父子关系一般不会以继承关系来实现,而是以组合的方式复用父类加载器。
            工作过程:一个类加载器收到类加载请求时,首先将请求委派给父类加载器完成,因此所有的类加载工作都从顶层向下传递,只有当父类加载器无法完成加载任务,子类加载器才会尝试加载。

附零散知识点:

JIT编译器:部分商用虚拟机中,Java程序最初是通过解释器对.class文件进行解释执行的,当虚拟机发现某个方法或代码块运行的特别频繁时,就会认为这些代码是热点代码Hot Spot Code。为了提高热点代码的执行效率,运行时虚拟机会将这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器叫即时编译器(JIT编译器)

到了Java 7之后,常量池已经不在持久代之中进行分配了,而是移到了堆中。
接着到了Java 8之后的版本,持久代已经被永久移除,取而代之的是Metaspace(元数据区)。
Metaspace与持久代最大的区别在于:Metaspace并不在虚拟机内存中而是使用本地内存。

JDK 8 中永久代向元空间(Metaspace)的转换的几点原因
1、字符串存在永久代中,容易出现性能问题和内存溢出。
2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
4、Oracle 可能会将HotSpot 与 JRockit 合二为一。

可以被invokestatic、invokespecial调用的方法,符合这个条件的有静态方法,私有方法,实例构造器,父类方法。由于其不能被继承或采用其他方法重写成其他版本,在类加载阶段就完成了将其符号引用解析为对应的直接引用,这些方法也称非虚方法。
另外被final修饰的方法,虽然是采用invokevirtual进行调用,但是它无法被覆盖,只有一个版本,所以也是非虚方法。

Human man = new Man();
左边为静态类型,右边为实际类型,静态类型在编译器可知,实际类型运行到此代码才可知

Java线程实现
就SUN JDK来说,他的Windows和Linux版本都采用的是一对一的线程模型,一条Java线程映射到一条轻量级进程中。

协同式线程:线程执行时间由线程本身来控制,线程把自己的工作执行完后会主动通知系统切换到另一个线程执行,也就不存在线程同步问题。
抢占式线程:每个线程由系统来分配执行时间,线程的切换不用线程自己决定。

线程安全的解释:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章