《深入理解Java虚拟机》读书笔记

wshunli
2018-05-17 / 0 评论 / 71 阅读 / 正在检测是否收录...

Java 虚拟机也是需要学习的一块内容 ,这次选择的书籍是 《深入理解Java虚拟机:JVM高级特性与最佳实践》。

Java 虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。 JVM 屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

第一部分 走进 Java

第一章 走进 Java

主要介绍了 Java 的历史、现在和未来的发展趋势。

Java技术体系

Java 技术体系

第二部分 自动内存管理机制

第二章 Java 内存区域与内存溢出异常

本章介绍 Java 虚拟机内存的各个区域的作用、服务对象及其可能出现的问题。

运行时数据区域

Java虚拟机运行时数据区

1、程序计数器

当前线程所执行的字节码的行号指示器。

唯一一个没有规定任何 OOM 情况的区域。

2、Java 虚拟机栈

虚拟机栈描述的是 Java 方法执行的内存模型。

每个方法在执行的同时都会创建一个栈帧(Stack Frame)(指向堆的地址)用于存储局部变量表、操作数栈、动态连接、方法出口等信息
每个方法从调用直至执行完成的过程==>一个栈帧在虚拟机栈中入栈到出栈的过程

3、本地方法栈

本地方法栈为虚拟机使用到的 Native 方法服务。

4、Java 堆

所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放对象实例。

5、方法区

各线程共享内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

运行时常量池

运行时常量池是方法区的一部分。

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

直接内存

不是虚拟机运行时数据区的一部分,也与 Java 虚拟机规范中定义的内存区域无关。

基于通道(Channel)与缓冲区(Buffer)的I/O方式。

它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。
这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

Java虚拟机运行时数据区-学习记录-51CTO博客:
http://blog.51cto.com/turnsole/2057198

HotSpot 虚拟机对象

本节主要介绍了对象的创建、内存布局及访问定位的问题。

OutOfMemoryError 异常

在 Java 虚拟机规范的描述中,除程序计数器外,虚拟机内存的其他几个运行区域都有可能发生 OOM 异常。

第三章 垃圾收集器与内存分配策略

前面介绍了 Java 内存运行时各区域,其中程序计数器、虚拟机栈、本地方法栈随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊得执行者出栈和入栈操作。而每个栈帧分配的内存基本确定,内存的分配和回收也就确定了,方法结束或者线程结束后,内存自然就跟着回收了。

Java 堆和方法区 不一样,这部分的内训分配和回收都是动态的,所以垃圾收集器主要关注的指这部分内容。

对象是否存活

垃圾收集器在对堆进行回收前,应该确定对象是否存活。

(1)引用计数法

给对象中添加一个引用计数器,每当一个地方引用它时,计数器值就加 1,当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。

引用计数法很难解决对象之间的相互循环引用问题。

ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

// 假设在这行发生GC,objA和objB是否能被回收?
System.gc();

上述代码,当方法运行完成后,对象就不能再被访问,可是 objA <> objB 循环引用着。

而 Java 垃圾收集器会回收内存,所以 Java 虚拟机不是通过引用计数器来判断对象是否存活的。

(2)可达性分析算法

通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。

在 Java 语言中,可作为 GC Roots 的对象包括下面几种:

1、虚拟机栈(栈帧中的本地变量表)中引用的对象。
2、方法区中类静态属性引用的对象。
3、方法区中常量引用的对象。
4、本地方法栈中JNI(即一般说的Native方法)引用的对象。

(3)对象引用

Java 对象引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4中,这 4 中引用强度异常逐渐减弱。

1、强引用就是指在程序代码之中普遍存在的,类似 Object obj = new Object() 这类的引用,只要强引用还存在,来及收集器永远不会回收掉被引用的对象。

2、软引用是用来描述一些还在用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK1.2 之后,提供了 SoftReference 类来实现软引用。

3、弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论是当前内存是否足够,都会回收掉只被若引用关联的对象。在 JDK1.2 之后,提供了 WeakReference 类来实现弱引用。

4、虚引用也成为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间过长影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知,在 JDK1.2 之后,提供了 PhantomReference 类来实现虚引用。

(4)对象的回收的两次标记过程

在可达性分析算法中不可达的对象被回收,要警告过两次标记过程:

1、第一次标记的同时会进行一次筛选,筛选此对象是否有必要执行 finalize() 方法。

当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

2、如果有必要执行 finalize() 方法,此对象会放到 F-Queue 队列中,稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。

对象只要在 finalize() 方法中重新与引用链上的任何一个对象建立关联即可避免被回收。

/**
 * 此代码演示了两点: 
 * 1.对象可以在被GC时自我拯救。 
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 */
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

注意,finalize() 方法只会被系统自动调用一次。

(5)回收方法区

方法区垃圾收集主要回收两部分:废弃常量和无用的类。

对于常量,没有任何对象引用常量池中的常量,虚拟机就可以把常量清出常量池。

对于类,同时满足以下三个条件才能算是 “无用的类”:

1、该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
2、加载该类的 ClassLoader 已经被回收。
3、该类对应的 java.lang.Class 对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法。

虚拟机 可以 对满足上述3个条件的无用类进行回收。

垃圾收集算法

(1)标记 — 清除算法(Mark-Sweep)

首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

垃圾收集算法1

它的主要不足有两个:
1、效率问题,标记和清除两个过程的效率都不高;
2、标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序中运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

(2)复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只是用其中的一块,当这一块的内存用完了,就将存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

垃圾收集算法2

现在的商业虚拟机都采用这种收集算法来回收 新生代 ,新生代的对象98%是“朝生夕死”的,所以并不需要按照 1:1 比例来划分内存空间。

(3)标记 — 整理算法(Mark-Compact)

“标记-整理”算法,标记过程任然与“标记-清理”算法一样,但后续步骤不是直接可对回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

垃圾收集算法3

(4)分代收集算法(Generational Collection)

当前商业虚拟机的垃圾收集都采用“分代收集”算法,一般是 Java 堆分成新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批量的对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成手机。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

Java虚拟机内存分配策略 | hua的博客:
http://www.zhangrenhua.com/2016/11/09/Java虚拟机内存分配策略/

垃圾收集器

垃圾收集器就是内存回收的具体实现。新生代的垃圾回收器包括 Serial、ParNew、Parallel Scavenge,老年代的垃圾回收器包括 CMS、Serial Old、Parallel Old。其中新生代的三种垃圾回收器都采用了复制算法。

![垃圾收集器](https://img.wshunli.com/Java/深入理解Java虚拟机/垃圾收集器.png)

1、Serial 收集器

Serial 收集器是一个单线程收集器,这个“单线程”不只是说它只会使用一个 CPU 或者一条线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它垃圾收集结束。它对于运行在 client 模式下的虚拟机来说是一个不错的选择。

垃圾收集器1

2、ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,由于除了 Serial 收集器外,只有它能够与 CMS 收集器配合工作,因此,在运行在 Server 模式下的虚拟机中,ParNew 收集器是首选的新生代收集器。

垃圾收集器2

3、Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一个并行的新生代垃圾收集器,不同于其他收集器(以尽可能缩短垃圾收集时用户线程的停顿时间为目的),它是唯一一个以达到一个可控制的吞吐量为目标的垃圾收集器。

吞吐量 = 运行用户代码的时间 / 总时间(垃圾收集时间+运行用户代码的时间)。

在后台运算的任务中,不需要太多的交互,保证运行的高吞吐量可以高效地利用CPU时间,尽快完成程序的运算任务。

垃圾收集器3

4、Serial Old 收集器

Serial Old 收集器是 Serial 收集器的老年代版本,同样是单线程的收集器,使用标记–整理算法对老年代垃圾进行回收。

它主要的两大用途:1. 配合 Parallel Scavenge 收集器;2. 作为 CMS 收集器在并发收集出现 Concurrent Mode Failure 时使用的后备预案。

垃圾收集器4

5、Parallel Old 收集器

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和标记整理算法。

在注重吞吐量和 CPU 资源敏感的场合,优先考虑使用 Parallel Scavenge + Parallel Old 收集器的组合,切记 Parallel Scavenge 是无法与 CMS 收集器组合使用的。

垃圾收集器5

6、Concurrent Mark Sweep 收集器

CMS 收集器是一款并发收集器,是一种以获取最短回收停顿时间为目标的收集器,它是基于标记–清除算法实现的。

它整个过程包含四个有效的步骤:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

CMS的主要优点是并发收集、低停顿,也称之为并发收集低停顿收集器(Concurrent Low Pause Collector)。

垃圾收集器6

7、 G1 收集器

G1 基于“标记–整理”算法实现,不会产生空间碎片,对于长时间运行的应用系统来说非常重要;另外它可以非常精准地控制停顿,既能让使用者指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1 收集器可以实现在基本不牺牲吞吐的前提下完成低停顿的内存回收,这是由于它能够避免全区域的垃圾回收,而 G1 将 Java 堆(包括新生代、老生代)划分成多个大小固定的独立区域,并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的由来)。

JVM(二)垃圾收集算法与收集器 – charles:
http://alicharles.com/article/jvm-gc/

内存分配与回收策略

Java 技术体系中所提倡的自动内存管理最终可归结为自动化解决两个问题:给对象分配内存以及回收分配给对象的内存。

1、对象优先分配在 Eden 区

大多数情况下,对象首先会被分配到新生代 Eden 区,当 Eden 区满了,会触发一次 Minor GC 。

2、大对象直接进入老年区

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的 byte[] 数组就是典型的大对象)。

虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(新生代采用复制算法收集内存)。

3、长期存活的对象进入老年区

对象在 Survivor 区中每“熬过”一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

4、动态对象年龄的判断

虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

5、空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。

如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。

如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC ,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次 Full GC ;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC 。

读《深入理解Java虚拟机》 - 轩宇一页 - 博客园:
http://www.cnblogs.com/zhouxuanyu/p/6942417.html

本章介绍了垃圾收集的算法、垃圾收集器的特点及运作原理、Java 虚拟机中的自动内存分配与回收机制。

第四章 虚拟机性能监控与故障处理工具

本章介绍了一些命令行及可视化的故障处理工具。

第五章 调优案例分析与实战

本章介绍了一些案例及调优方法。

第三部分 虚拟机执行子系统

第六章 类文件结构

Sun 公司及其他虚拟机提供商发布了许多可以运行在各种不同平台上的虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现“一次编写,到处运行”。

语言无关性

Java Class文件结构如下图所示:

Class文件结构

对于以下 Java 源文件代码:

public class Main {

    public static void main(String[] args) {

        System.out.println("Hello World!");
    }
}

生成的类文件字节码为:

Java字节码

JVM(四)类文件结构解析 – charles:
http://alicharles.com/article/jvm-class/

第七章 虚拟机类加载机制

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

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括以下7个阶段:加载,验证,准备,解析,初始化,使用,卸载。

类的生命周期

其中加载,验证,准备,初始化,卸载这5个阶段的顺序是确定。而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的运行时绑定(也称为动态绑定或晚期绑定)。

类加载的过程

类加载的过程也就是类加载机制,分为 加载,验证,准备,解析,初始化 这 5 个阶段。

1、加载

“加载”是“类加载”过程中的一个阶段。在加载阶段,虚拟机会做 3 件事:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

2、验证

验证是连接阶段的第一步。目的是确保 Class 文件的字节流中包含的信息不会危害到虚拟机自身的安全。

包含:文件格式验证,元数据验证,字节码验证,符号引用验证。

3、准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

其中初始值“通常情况下”是数据类型的零值。

4、解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用和直接引用的关联如下:

符号引用(Symbol References): 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须一致,因为符号引用的字面量形式明确定义在Java虚拟机规范的 Class 文件格式中。

直接引用(Direct References): 直接引用可以是直接目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局有关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必定已经在内存中存在。

5、初始化

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源,简单说,初始化阶段即虚拟机执行类构造器 <clinit>() 方法的过程。

下面来详细讲解<clinit>()方法是怎么生成的,首先来了解此方法执行过程中可能会影响到程序运行行为的特点和细节:

  1. <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块( static{} 块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定,特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
public class Test {
    static {
        i = 0;  // 给变量复制可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”  
    }
    static int i = 1;
}
  1. <clinit>()方法与类的构造函数(或者说实例构造器 <init>() 方法)不同,不需要显式的调用父类的()方法。虚拟机会自动保证在子类的<clinit>()方法运行之前,父类的<clinit>()方法已经执行结束。因此虚拟机中第一个执行<clinit>()方法的类肯定为java.lang.Object。

  2. 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。例如以下代码:

static class Parent {
        public static int A = 1;
        static {
            A = 2;
        }
}

static class Sub extends Parent {
        public static int B = A;
}

public static void main(String[] args) {
        System.out.println(Sub.B);//输出结果是父类中的静态变量值A,也就是2
}
  1. <clinit>()方法对于类或接口不是必须的,如果一个类中不包含静态语句块,也没有对类变量的赋值操作,编译器可以不为该类生成<clinit>()方法。
    接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的

  2. <clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

  3. 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的<clinit>()方法,其它线程都会阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时的操作,就可能造成多个进程阻塞,在实际过程中此种阻塞很隐蔽。

深入理解JVM(5)——虚拟机类加载机制 - 王泽远的博客 | Crow's Blog :
https://crowhawk.github.io/2017/08/21/jvm_5/

类加载器

类加载器负责,通过一个类的全限定类名来获取描述此类的二进制字节流。

对于任意一个类,都需要由他的类加载器和这个类本身共同确立其在 Java 虚拟机中的唯一性。

package com.wshunli.jvm.demo;

import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {

        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };

        Object obj = myLoader.loadClass("com.wshunli.jvm.demo.ClassLoaderTest").newInstance();

        System.out.println(obj.getClass());
        System.out.println(obj instanceof com.wshunli.jvm.demo.ClassLoaderTest);
    }
}
/*
 * class com.wshunli.jvm.demo.ClassLoaderTest
 * false
 */

每一个类加载器,都拥有一个独立的类名称空间。简言之,比较两个类是否“相等”只有在这两个类是由同一个类加载器加载的前提下才有意义。

(1)双亲委派模型

从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:

一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;
另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

从Java开发人员的角度来看,有三种类加载器:

启动类加载器 (Bootstrap ClassLoader):负责加载<JAVA_HOME>\lib目录或者由参数 -Xbootclasspath 指定路径中并且是虚拟机识别的类库加载到虚拟机内存中。

扩展类加载器 (Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录中或者被 java.ext.dirs 系统变量指定路径中所有的类库。

应用程序加载器 (Application ClassLoader):负责加载由 CLASSPATH 指定的类库,如果程序没有自定义类加载器,程序默认使用该加载器。

类加载器的双亲委派模型:

类加载器

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

实现双亲委派的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法之中,实现如下:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

双亲委派模型的实现逻辑:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的findClass() 方法进行加载。

第八章 虚拟机字节码执行引擎

本章从虚拟机字节码执行引擎的概念模型角度来介绍虚拟机方法调用和字节码执行。

运行时帧栈结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

每一个方法从调用开始到执行完成的过程都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

运行时栈帧结构

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

按照调用方式共分为两类:

解析调用 是静态的过程,在编译期间就完全确定目标方法。

分派调用 即可能是静态,也可能是动态的,根据分派标准可以分为单分派和多分派。两两组合有形成了静态单分派、静态多分派、动态单分派、动态多分派。

(1)解析

所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。

(2)分派

1、静态分派

所有依赖静态类型3来定位方法执行版本的分派成为静态分派,发生在编译阶段,典型应用是方法重载。

2、动态分派

在运行期间根据实际类型4来确定方法执行版本的分派成为动态分派,发生在程序运行期间,典型的应用是方法的重写。

基于栈的字节码解释执行引擎

Java 编译器输入的指令流基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。

另外一种指令集架构则是基于寄存器的指令集架构,典型的应用是 x86 的二进制指令集,比如传统的 PC 以及 Android 的 Davlik 虚拟机。

两者之间最直接的区别是:
基于栈的指令集架构不需要硬件的支持,而基于寄存器的指令集架构则完全依赖硬件,
这意味基于寄存器的指令集架构执行效率更高,单可移植性差,而基于栈的指令集架构的移植性更高,但执行效率相对较慢,除此之外,相同的操作,基于栈的指令集往往需要更多的指令。

第九章 类加载级执行子系统的案例及实战

本章主要介绍了类加载器和字节码的案例。

第四部分 程序编译与代码优化

第十章 早期(编译器)优化

第十一章 晚期(运行器)优化

第四部分主要介绍了 Java 源程序从源代码编译成字节码和从字节码编译成本地机器码的过程,从 javac 字节码编译器到虚拟机内的 JIT 编译器执行过程合并起来其实就等同于一个传统编译器所执行的编译工程。

第五部分 高效并发

Java 内存模型与线程

Java 虚拟机规范试图定义一种内存模型 (Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,实现 Java 程序在各种平台下都能达到一致的内存访问效果。

Java 内存模型

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。

此处的变量与 Java 编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

Java内存模型

Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面将的处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,线程、主内存和工作内存的交互关系如图所示。

(1) 内存间交互操作

一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,JMM 定义了一下八种操作来完成:

  • lock(锁定):作用域主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,它变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的,如对主内存中的变量 a、b 进行访问时,可能的顺序是 read a,read b,load b, load a。

Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许 read 和 load、store 和 write 操作之一单独出现,如不允许从主内存读取了但工作内存不接受
  • 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

深入理解JVM之高效并发 - 倔强的荔枝:
http://wanglizhi.github.io/2016/07/16/JVM-JMM-And-Thread/

(2) 对于volatile型变量的特殊规则

使用 volatile 变量可以保证线程之间的可见性,再者禁止指令重排序优化。

由于 volatile 变量只能保证可见性,在 不符合 以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他状态变量共同参与不变约束

(3) 对于long和double型变量的特殊规则

JVM 规范允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行。

但是各种虚拟机实现几乎把 64 位数据的读写作为原子操作来对待。

(4) 原子性、可见性和有序性

原子性(Atomicity):大致认为基本数据类型的访问读写是具备原子性的。JMM 提供 lock 和 unlock 保证原子性,对应代码中的 synchronized 关键字。

可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。volatile 保证新值能立即同步到主内存,以及每次使用前立即从主内存刷新。除了volatile外,synchronized 和 final 两个关键字也能实现可见性,其中同步块是有 lock 和 unloc k机制决定的,而 final 关键字一旦初始化完成,其他线程就能看见 final 字段的值。

有序性(Ordering):如果在本线程内观察,所有的操作都是有序的;如果在一个线程内观察另一个线程,所有操作都是无序的。Java 提供了 volatile 和synchronized 关键字来保证线程之间操作的有序性。

(5) 先行发生原则

先行发生原则:如果操作 A 先发生于操作 B,操作 A 产生的影响能被操作 B 观察到,“影响”包括:修改了内存中共享变量的值、发送了消息、调用了方法。

  • 程序次序规则:写在程序签名的操作先行发生于书写在后面的操作
  • 管程锁定规则:一个 unlock 操作先行发生于后面对 同一个锁 的 lock 操作
  • volatile变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread 对象的 start 方法先行发生于此线程的每一个动作
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象初始化完成先行发生于它的 finalize 方法的开始
  • 传递性:如果操作 A 先于操作 B,操作 B 先行于操作 C,那么操作A先行发生于操作 C

Java 与线程

(1)线程的实现

实现线程主要有三种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级混合线程实现。

(2)Java 线程调度

线程调剂是指系统为线程分配处理器使用权过程:协同式线程调度、抢占式线程调度。

协同式调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完后,要主动通知系统切换到另一个线程上去。

抢占式调度:每个线程由系统来分配执行时间,线程切换不由线程本身来决定。Java 使用的就是抢占式调度。

Java 一种设置了 10 个级别的线程优先级,在两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。但是并不是太靠谱,优先级可能会被系统自行改变。

(3)线程状态转换

Java 语言定义了六种线程状态。

  1. 新建(New):创建后尚未启动的线程处于这种状态。

  2. 运行(Runable):包括了操作系统线程状态中的 Running 和 Ready,可能正在执行,也可能等待着CPU为它分配执行时间。

  3. 无限期等待(Waiting):处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被其他线程显式地唤醒。

  4. 限期等待(Timed Waiting):处于这种状态也不会被分配 CPU 执行时间,在一定时间之后它们由系统自动唤醒。

  5. 阻塞(Blocked):在等待获取一个排它锁,这个时间将在另外一个小城放弃这个锁的时候发生;在等待进入同步区域的时候。

  6. 结束(Terminated):已终止线程的线程状态。

深入理解Java虚拟机——高效并发 - CSDN博客:
https://blog.csdn.net/hanxueyu666/article/details/53729491

第十三章 线程安全与锁优化

线程安全

当多个线程接见一个对象时,若不考虑这些线程在运行时环境下的调度和交替执行,也不须要进行额外的同步,或者调用方进行任何其他的协调操纵,调用这个对象的行动都可以获得正确的成果,那这个对象就是线程安全的。

线程安全强弱分成五类:

1、不可变:只要一个不可变的对象被正确地构建出来。

应用 final 关键字修饰的基本数据类型;若是共享数据是一个对象,那就须要保证对象的行动不会对其状况产生任何影响(String 类的对象)。

办法:把对象中带有状况的变量都申明为 final ,如 Integer 类。除 String 以外还有列举类型、Number 的部分子类(AtomicInteger 和 AtomicLong 除外)。

2、绝对线程安全:不管运行时环境如何,调用者都不需要任何额外的同步措施。大部分 Java API 都不是绝对的线程安全。

3、相对线程安全:它需要包成对这个对象单独的操作时线程安全的,对于一些特定的顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

4、线程兼容:对象本身并不是线程安全的,需要经由过程调用规矩确地应用同步手段来保证对象在并发环境中安全地应用。

5、线程对立:不管调用端是否采取了同步措施,都无法在多线程环境中并发应用的代码。

如:Thread 类的 suspend() 和 resume() 方法,System.setIn()、System.setOut()、System.runFinalizersOnExit()。

线程安全的实现方法

1、互斥同步

互斥同步(Mutual Exclusion & Synchronization)是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

2、非阻塞同步

互斥同步主要问题是进行现场阻塞和唤醒的性能问题,这种同步称为阻塞同步,另外它属于一种悲观的并发策略,总是认为不加锁肯定会出问题。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗讲就是先进行操作,没有冲突就成功,有冲突就进行补偿(如重试直到成功),这种乐观的并发策略成为非阻塞同步。

3、无同步方案

要保证线程安全,并不一定要进行同步,如果一个方法不涉及共享数据,那它就无须任何同步措施去保证正确性。

可重入代码(Reentrant Code):也叫做纯代码,可以在代码执行的任何时刻中断它,转而执行另一段代码,返回后不会出现任何错误。可重入代码有一些共同的特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态都由参数传入、不调用非可重入的方法等。

线程本地存储(Thread Local Storage):把共享数据的可见范围限制在同一个线程内,无须同步,如:经典Web交互模型中的”一个请求对应一个服务器线程“的处理方式。java.lang.ThreadLocal 类来实现线程本地存储的功能,每个线程的 Thread 对象都有一个 ThreadLocalMap 对象,以 threadLocalHashCode 为键,以本地线程变量为值。

锁优化

本部分介绍了一些锁优化的技术,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。

本书也看完了,只是选择了比较重要的部分仔细阅读,其他的都是大概浏览一遍,后面有机会再读加深理解。

1

评论 (0)

取消