深入理解JVM(整理)

1、JVM概念

运行Java程序的虚拟计算机。

是Java语言的运行环境(Runtime)。

2、内存区域与内存溢出

2.1概述

JVM自动内容管理机制,避免了JAVA程序员编写对象的delete/free,却也仍需要去了解JVM怎么使用内存的,避免出现问题时不知如何解决。 

2.2运行时数据区域

2.2.1 程序计数器

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

每个线程都有一个独立的程序计数器。

此内存区域 是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2.2.2Java虚拟机栈

Java虚拟机栈也是线程私有的。

描述的Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口 等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出 栈的过程。

2.2.3本地方法栈

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

2.2.4Java堆

线程共享。

在虚拟机启动时创建,存放对象实例。所有的对象实例以及数组都要在堆上分配。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。从内存回收的角度来看,由于现在收集器基 本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有 Eden空间、From Survivor空间、To Survivor空间等。

-Xmx、-Xms。

2.2.5方法区

线程共享。

用于存储已被虚 拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

2.2.6运行时常量池

方法区的一部分。

Class文件常量池:编译期生成的各种字面量和符号引用

运行时常量池:运行期间也可能将新的常量放入池中,例如是String类的inter()方法

2.2.7直接内存

本机直接内存.

2.3HotSpot虚拟机对象

2.3.1对象的创建

对象创建过程:

  1. 类加载
  2. 为新生对象分配内存
  3. 初始化(属性置零)
  4. 设置对象必备信息(对象头)
  5. Init

1.类加载:执行类加载过程,加载、解析、初始化

2.分配内存:对象所需内存的大小在类 加载完成后便可完全确定

    为新生对象分配内存根据堆内存是否规整分为:指针碰撞、空闲列表

    保证分配时线程安全两种方式:1.同步处理分配动作-CAS失败重试、2不同线程预留空间-(本地线程缓冲)TLASB

3.初始化:将实例字段初始化值

4.对对象进行必要设置:例如这个对象是哪个类的实例、如何才能找 到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对 象头(Object Header)之中。

5.init: 把对象按照程序员的意愿进行初始化

2.3.2对象的内存布局

对象可分为3块区域:对象头、对象实例数据、对齐填充。

对象对象头对象自身运行时数据
类型指针
对象实例数据
对齐填充

1.对象头:包括两部分信息

第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和 64bit,官方称它为“Mark Word”。

对象需要存储的运行时数据很多,其实已经超出了32位、 64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储 成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的 空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的 HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中的25bit用 于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在 其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见表2-1。

 另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指 针来确定这个对象是哪个类的实例。

2.实例数据部分:对象真正存储的有效信息,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

3.对齐填充:占位符的作用。 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说, 就是对象的大小必须是8字节的整数倍。

2.3.3对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的 具体对象。

主流的访问方式有使用句柄和直接指针两种。


句柄栈中Reference不用变定位对象要两次
直接指针一次即可定位栈中Reference要变

2.4OutOfMemoryError异常

2.4.1Java堆溢出

-XX: +HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆 转储快照以便事后进行分析

-XX:HeapDumpPath 指定Dump日志输出地址

2.4.2虚拟机及本地方法栈溢出

2.4.3方法区和运行时常量池溢出

intern()将字符串放入常量池:

  • 1.6版本 第一次出现,将实例复制至常量池,并返回常量池地址。
  • 1.7版本 第一次出现,将实例引用放置常量池,并返回实例实际地址。

2.4.4本即直接内存溢出

2.5本章小结

通过本章的学习,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代 码和操作可能导致内存溢出异常。虽然Java有垃圾收集机制,但内存溢出异常离我们仍然并 不遥远,本章只是讲解了各个区域出现内存溢出异常的原因,第3章将详细讲解Java垃圾收 集机制为了避免内存溢出异常的出现都做了哪些努力。

3、垃圾收集器与内存分配策略

3.1 概述

哪些内存需要回收?

什么时候回收?

如何回收?

3.2 对象已死吗

引用计数法实现简单、效率高循环引用问题
可达性分析准确

3.2.1 引用计数算法

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

循环引用缺陷:对象objA和objB都有字段 instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引 用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引 用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

/**
 *testGC()方法执行后,objA和objB会不会被GC呢?
 *@author zzm
 */
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        //假设在这行发生GC,objA和objB是否能被回收?
        System.gc();
    }
}

3.2.2 可达性分析算法

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

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

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

3.2.3 再谈引用

强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种。

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

软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。内存不够才会回收。

弱引用关联的 对象只能生存到下一次垃圾收集发生之前。

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

3.2.4 生存还是死亡

要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达 性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

finalize()方法逃过回收;

/**
 * 此代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 *
 * @author zzm
 */
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();
//因为finalize方法优先级很低,所以暂停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();
//因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
        SAVE_HOOK.isAlive();
    }else{
        System.out.println("no,i am dead:(");
    }
    }
}

3.2.5 回收方法区

回收两部分内容:废弃常量和无用的类。

3.3 垃圾收集算法

标记-清除简单效率低;碎片空间
复制效率高;无碎片空间浪费空间
标记-整理无碎片;无浪费效率低
分代

3.3.1 标记-清除算法(Mark-Sweep)

3.3.2 复制算法(Copying)

3.3.3 标记-整理算法(Mark-Compact)

3.3.4 分代收集算法

没有新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆 分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

3.4 HotSpot的算法实现

3.4.1 枚举根节点

可达性分析必须在一 个能确保一致性的快照中进行——指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况。

GC进行时必须停顿所有 Java执行线程(“Stop The World”)。

当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,使用一组称为OopMap的数据结构直接得知哪些地方存放着对象引用,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

3.4.2 安全点

在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举。

HotSpot没有为每条指令都生成OopMap,只是在“特定的 位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定是以程序“是否具有让程序长时间执行的特征”为标准进行选定的

3.4.3 安全区域

3.5 垃圾收集器

3.5.1 Serial收集器

新生代单线程串行收集器

复制算法

3.5.2 ParNew收集器

新生代多线程并行收集器

复制算法

-XX:+UseParaNewGc

3.5.3 Parallel Scavenge收集器

关注吞吐率(CPU工作时间比例):CPU运行时间/ (CPU运行时间 + GC时间)   

-XX:MaxGCPauseMillis  最大垃圾收集停顿时间

-XX:GCTimeRatio 吞吐量大小 (如果把此参数设置为19,那允许的最大GC时间就占总 时间的5%(即1/(1+19)))

3.5.4 Serial Old收集器

标记-整理

3.5.5 Parallel Old收集器

标记整理

关注吞吐率

只能搭配Parallel Scavenge 使用

3.5.6 CMS收集器(Concurrent Mark Sweep)

标记-清除

4个步骤:

  1. 初始标记    (只标记GC Roots能直接关联的对象    需Stop The World)
  2. 并发标记    (进行GC Roots Tracing    与用户线程同步)
  3. 重新标记    (标记并发标记时用户线程又产生的垃圾  需Stop The World)
  4. 并发清除    (清除垃圾 与用户线程同步)

缺点:

  1. CPU资源敏感 回收线程占(CPU核数 + 3)/4
  2. 无法处理并发清除时的浮动垃圾,需预留老年代空间(-XX:CMSInitiatingOccupancyFaction 收集器启动阀值),还需要Serial Old 回收器保底
  3. 标记-清理算法,需要额外处理碎片 (-XX:+UseCMSCompactAtFullCollection 开启内存碎片合并 ;-XX:CMSFullGCsBeforeCompaction 执行多少次不压缩的Full GC后跟着来一次带压缩的)

3.5.7 G1收集器(Garbage First)

特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者 CPU核心)来缩短Stop-The-World停顿的时间. 
  • 分代收集:分代概念在G1中依然得以保留。
  • 空间整合:G1从整体来看是基于“标记—整理”实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的;G1运作期间不会产生内存空间碎片.
  • 可预测的停顿:降低停顿时间是G1和CMS共同的关 注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能指定在一 个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

分Region收集

维护一个垃圾回收优先列表:回收价值排序

维护一个Remembered Set: 记录对外部的引用

步骤:

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收

3.5.8 理解GC日志

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]

100.667:[Full GC[Tenured:0K->210K(10240K),0.0149142 secs]4603K->210K(19456K),[Perm:2999K-> 2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]

  • 最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java 虚拟机启动以来经过的秒数。
  • “[GC”和“[Full GC”说明垃圾收集的停顿类型,不是用来区分新生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的
  • “[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,与GC收集器密切相关。(DefNew->Serial、ParNew->ParNew、PSYoungGen->Parallel Scavenge)
  • 后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量-> GC后该内存区域已使用容量(该内存区域总容量)”
  • 方括号之外的“3324K-> 152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容 量)”

3.5.9 垃圾收集器参数总结

3.6 内存分配与回收策略

Minor GC: 新生代

Major GC:老年代

FULL GC:堆+方法区

3.6.1 对象优先在Eden分配

private static final int _1MB=1024*1024;
/**
 *VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails
 -XX:SurvivorRatio=8
 */
public static void testAllocation(){
        byte[]allocation1,allocation2,allocation3,allocation4;
        allocation1=new byte[2*_1MB];
        allocation2=new byte[2*_1MB];
        allocation3=new byte[2*_1MB];
        allocation4=new byte[4*_1MB];//出现一次Minor GC
}

3.6.2 大对象直接进入老年代

-XX:PretenureSizeThreshold 大于这个设置值的对象直接在老 年代分配

-XX:PretenureSizeThreshold=3145728

3.6.3 长期存活的对象将进入老年代

-XX:MaxTenuringThreshold 对象晋升老年代的年龄阈值

private static final int _1MB=1024*1024;
/**
 *VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=1
 *-XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold(){
        byte[]allocation1,allocation2,allocation3;
        allocation1=new byte[_1MB/4];
        //什么时候进入老年代取决于XX:MaxTenuringThreshold设置
        allocation2=new byte[4*_1MB];
        allocation3=new byte[4*_1MB];
        allocation3=null;
        allocation3=new byte[4*_1MB];
}

3.6.4 动态对象年龄判定

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

3.6.5 空间分配担保

-XX:-HandlePromotionFailure

发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。

如果这个条件成立,那么Minor GC可以确保是安全的。

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

3.7 本章小结

本章介绍了垃圾收集的算法、几款JDK 1.7中提供的垃圾收集器特点以及运作原理。

通过代码实例验证了Java虚拟机中自动内存分配及回收的主要规则。 内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟 机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求、 实现方式选择最优的收集方式才能获取最高的性能。

没有固定收集器、参数组合,也没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。因此,学习虚拟机内存知识,如 果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数。在接下来的两章中,作者将会介绍内存分析的工具和调优的一些具体案例。

4 虚拟机性能监控、故障处理工具

4.1 概述

数据是依据,工具手段。数据包括但不限于异常堆栈、虚拟机运行日志、垃圾收集器日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。

常用工具

命令/工具概述
命令行工具jps虚拟机进程状况工具
jstat虚拟机统计信息监视工具
jinfoJava配置信息工具
jmapJava内存映像工具
jhat虚拟机堆转储快照分析工具
jstackJava堆栈跟踪工具
可视化工具JHSDB基于服务性代理的调试工具(JDK 9)
JConsoleJava监视与管理控制台
VisualVM多合-故障处理工具
JMC可持续在线的监控工具

4.2命令行工具

所有的命令都可以用 -help 查看帮助(同UNIX命令)

4.2.1 jps:虚拟机进程状况工具(JVM ProcessStatus Tool)

作用:列出正在运行的虚拟机进程、显示虚拟机执行主类名称、进程的本地虚拟机唯一ID(LVMID,LocalVirtual Machine Identifier)

命令格式: jps [options] [hostid]

示例:

4.2.2 jstat:虚拟机统计信息监视工具(JVM Statistics Monitoring Tool)

作用:用于监视虚拟机各种运行状态信息的命令行工具,显示本地或者远程[插图]虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。

命令格式为:jstat [ option vmid [internal[s|ms]] [count] ]

示例:

4.2.3 jinfo:Java配置信息工具(Configuration Info for Java)

作用:实时查看和调整虚拟机各项参数

命令格式: jinfo [option] pid

使用-flag选项进行查询;

使用-flag[+|-]name或者-flag name=value在运行期修改一部分运行期可写的虚拟机参数值

4.2.4 jmap:Java内存映像工具(Memory Map for Java)

作用:用于生成堆转储快照(一般称为heapdump或dump文件)

命令格式: jmap [option] vmid

4.2.5 jhat:虚拟机堆转储快照分析工具(JVM Heap Analysis Tool)

作用:jmap搭配使用,来分析jmap生成的堆转储快照。

4.2.6 jstack:Java堆栈跟踪工具(Stack Trace for Java)

作用:用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)

命令格式 :   jstack [option] vmid

4.3可视化工具

4.3.1 JHSDB:基于服务性代理的调试工具

JHSDB是一款基于服务性代理(Serviceability Agent,SA)实现的进程外调试工具。

服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言(含少量JNI代码)实现的API集合。

4.3.2 JConsole:Java监视与管理控制台(Java Monitoring and Management Console)

可视化监视、管理工具,对系统进行信息收集和参数动态调整。

包括六个页签:“概述”、“内存”、“线程”、“类”、“VM摘要”、“MBean”

四项概览信息:“堆内存使用情况”、“线程”、“类”、“CPU使用情况”

4.3.3 VisualVM:多合-故障处理工具(All-in-One Java Troubleshooting Tool)

功能最强大的运行监视和故障处理程序之一,强大之处在于可扩展插件

4.3.4 Java Mission Control:可持续在线的监控工具

是一个用于对 Java 应用程序进行管理、监视、概要分析和故障排除的工具套件。

4.4 总结

介绍了随JDK发布的6个命令行工具与4个可视化的故障处理工具,灵活使用这些工具,可以为处理问题带来很大的便利。

5 调优案例分析与实战

6 类文件结构

6.1 概述

程序语言执行过程: 源文件 -> 字节码 ->二进制字节码

6.2 无关性的基石

基础是:JVM&字节码存储格式

平台无关性:各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——字节码(Byte Code)是构成平台无关性的基石。

语言无关性:Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。

6.3 Class类文件的结构

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列,没有任何分隔符,没有空隙存在。

Class文件格式采用一种伪结构存储数据,伪结构中只有两种数据类型:“无符号数”和“表”:

  • 无符号数:基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数。可用来描述数字、索引引用、数量值或按照UTF-8编码构成字符串值。
  • 表:由多个无符号数或其他表作为数据项构成的复合数据类型。习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据。

下述6.3章节全部使用此段代码做说明:

package org.fenixsoft.clazz;

public class TestClass {
    private int m;
    public int inc(){
        return m + 1;
    }
}

编译后字节码如下:

6.3.1 魔数与Class文件的版本

每个Class文件的头4个字节(0xCAFEBABE)被称为魔数(Magic Number),判断是否为class文件。

接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。

Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1。

(0x34) = 52, 故可得JDK版本为8(52-44),可被JDK8及更新版本的虚拟机执行。

6.3.2 常量池

资源仓库,存储内容如下

两大类常量细分类
常量池字面量文本字符串
声明为final常量值
...
符号引用类和接口的全限定名
字段的名称和描述符
方法的名称和描述符

由于常量池常量数量不固定,所以常量池入口放置constant_pool_count表明常量个数(计数从1开始,不是从0,即22代表有21项,索引为1-21)

截至JDK13,常量类型共有17种

(当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。即解析步骤:符号引用-> 直接引用)

17种常量结构如下:

拿6.3 TestClass代码举例说明:

使用javap命令反编译后更为清晰,21个常量及类型如下:

6.3.3 访问标志

常量池结束之后,紧接着的2个字节代表访问标志(access_flags)。用于识别类或者接口的访问信息,包括:这个Class是类还是接口、是否定义为public类型、是否定义为abstract类型、如果是类的话是否被声明为final;等等

以TestClass为例:ACC_PUBLIC、ACC_SUPER标志为真,其余为假,因此它的access_flags的值应为:0x0001|0x0020=0x0021。

6.3.4 类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后。

类索引和父类索引用两个u2类型的索引值表示,各自指向一个类型为CONSTANT_Class_info的类描述符常量,常量中的索引值可找到全限定名字符串。

6.3.5 字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。

字段可包括的修饰符有:作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符)、可否被序列化(transient修饰符)、数据类型(基本类型、对象、数组)、字段名称。

全限定名:“org/fenixsoft/clazz/TestClass”,是这个类的全限定名

简单名称:指没有类型和参数修饰的方法或者字段名称,“inc”、“m”

描述符:描述字段的数据类型、方法的参数列表和返回值,如:方法void inc()  -> “()V”,方法java.lang.String toString() ->  “()Ljava/lang/String;”,方法intindexOf(char[]source,int offset,int count)的描述符为“([CII)I”

TestClass字段表信息对应如下:

6.3.6 方法表集合

Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式。

6.3.7 属性表集合

1.Code属性

Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中。

2.Exceptions属性

6.ConstantValue属性

6.4 字节码指令简述

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。

Java虚拟机操作码的长度为一个字节(即0~255),操作码总数不能够超过256条。

6.4.1 字节码与数据类型

i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。

通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令

6.4.2 加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括:

  • 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>
  • 将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
  • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
  • 扩充局部变量表的访问索引的指令:wide

6.4.3 运算指令

算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

所有的算术指令包括:

  • 加法指令:iadd、ladd、fadd、dadd
  • 减法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求余指令:irem、lrem、frem、drem
  • 取反指令:ineg、lneg、fneg、dneg
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
  • 按位或指令:ior、lor
  • 按位与指令:iand、land
  • 按位异或指令:ixor、lxor
  • 局部变量自增指令:iinc
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

6.4.4 类型转换指令

Java虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):

  • int类型到long、float或者double类型
  • long类型到float、double类型
  • float类型到double类型

处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指令来完成,这些转换指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

6.4.5 对象创建与访问指令

Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:

  • 创建类实例的指令:new
  • 创建数组的指令:newarray、anewarray、multianewarray
  • 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
  • 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
  • 取数组长度的指令:arraylength
  • 检查类实例类型的指令:instanceof、checkcast

6.4.6 操作数栈管理指令

直接操作操作数栈的指令:

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
  • 将栈最顶端的两个数值互换:swap

6.4.7 控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序。

从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改PC寄存器的值。

控制转移指令包括:

  • 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
  • 复合条件分支:tableswitch、lookupswitch
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret

6.4.8 方法调用和返回指令

方法调用:

  • invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
  • invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  • invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic指令:用于调用类静态方法(static方法)。
  • invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的

6.4.9 异常处理指令

6.4.10 同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。

方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。

同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义.

6.5 公有设计、私有实现

一个优秀的虚拟机实现,在满足《Java虚拟机规范》的约束下对具体实现做出修改和优化也是完全可行的.

6.6 Class文件结构的发展

Class文件的主体结构、字节码指令的语义和数量几乎没有出现过变动[,所有对Class文件格式的改进,都集中在访问标志、属性表这些设计上原本就是可扩展的数据结构中添加新内容

6.7 总结

重点了解Class文件结构。

7 虚拟机类加载机制

7.1概述

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

7.2类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历七个周期。

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

其中验证、准备、解析三个部分统称为连接(Linking)。

Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

7.3类加载的过程

类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段。

7.3.1加载

获取class文件的二进制流,转化为方法区的运行时数据结构,并在内存中生成Class对象。(类加载器)

需要强调的是:类的二进制流来源很多。

7.3.2验证

确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证代码不会危害虚拟机自身的安全。

  1. 文件格式验证:校验class文件是否为正确格式
  2. 元数据验证:验证语义是否正确,是否满足java语言规范
  3. 字节码验证:验证方法体,即字节码命令不会存在危害行为
  4. 符号引用验证:对本类之外的符号引用进行匹配性校验,保证能转为直接引用,为解析铺路。

7.3.3准备

为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值。

只给类变量分配内存、设置零值,不执行其他赋值语句。

7.3.4解析

常量池内的符号引用替换为直接引用的过程。

即将class文件的常量池(CONSTANT_Class_info等类型的常量),转为实际类型的直接引用。

  • 符号引用(Symbolic References):描述所引用的目标的一组符号
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

7.3.5初始化

根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源,即执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,收集顺序由源文件中出现顺序决定。

Java虚拟机自身会保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步。

7.4类加载器

7.4.1 类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。

7.4.2 双亲委派模型

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

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

站在Java开发人员的角度来看,分为四大类:

  • 启动类加载器(Bootstrap Class Loader):负责加载存放在<JAVA_HOME>\lib目录,或被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的类库。
  • 扩展类加载器(Extension Class Loader):负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。
  • 应用程序类加载器(Application Class Loader):负责加载用户类路径(ClassPath)上所有的类库。
  • 自定义类加载器:可自行拓展,如增加除了磁盘位置之外的Class文件来源

双亲委派模型:一个类加载器收到了类加载的请求,它不会自己去加载这个类,而是把这个请求委派给父类加载器去完成。只有父加载器反馈无法完成这个加载请求时(搜索范围中没找到所需的类),子加载器才会尝试自己去完成加载。

代码逻辑:先检查请求加载的类是否已被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

7.4.3 破坏双亲委派模型

7.5 小结

介绍了类加载过程的“加载”“验证”“准备”“解析”和“初始化”这5个阶段,类加载器的工作原理。

8 虚拟机字节码执行引擎

8.1 概述

执行引擎是Java虚拟机核心的组成部分之一。

虚拟机”相对于“物理机”,都有代码执行能力。区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上,而虚拟机的执行引擎则是由软件自行实现的。

不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。

从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果,

8.2 运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

8.2.1 局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。

编译完成之后,即可确定一个方法的局部变量及需分配空间,存放于Code属性中的max_locals.

局部变量表的容量以变量槽(Variable Slot)为最小单位: 每个Slot都应能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据.

slot变量槽是可以被复用的,故一旦槽A被复用,之前被槽A变量引用的对象极有可能在方法执行的过程中就被回收掉。

8.2.2 操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last InFirst Out,LIFO)栈。

8.2.3 动态连接

Class文件的常量池中有大量的符号引用,这些符号引用一部分会在类加载阶段被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

8.2.4 方法返回地址

一个方法开始执行后,只有两种方式退出这个方法。

  • 第一种方式是执行引擎遇到任意一个方法返回的字节码指令:“正常调用完成”(Normal Method Invocation Completion)。
  • 另外一种退出方式是在方法执行的过程中遇到了异常,:“异常调用完成(Abrupt MethodInvocation Completion)”。

无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回地址用来帮助恢复它的上层主调方法的执行状态。

8.2.5 附件信息

一些规范里没有描述的信息,例如与调试、性能收集相关的信息,取决于具体的虚拟机实现。

8.3 方法调用

方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),不等同于方法中的代码执行。

分类说明
解析编译时即确定
分派静态分派
动态分派
单分派与多分派
虚拟机动态分派

5种方法调用字节码指令:

  • invokestatic。用于调用静态方法。
  • invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。
  • invokevirtual。用于调用所有的虚方法。
  • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

8.3.1 解析

调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。

  • 能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本:静态方法、私有方法、实例构造器、父类方法4种
  • 被final修饰的方法(invokevirtual)

8.3.2 分派

Human man = new Man()

“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type)

“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type)

静态分派

虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的.

动态分派

invokevirtual指令执行的第一步就是在运行期确定接收者的实际类.

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。

虚拟机动态分派

...

8.4 动态类型语言支持

何谓动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的。

相对地,在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

...

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

8.5.1 解释执行

Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。解释器在虚拟机的内部。

8.5.2 基于栈的指令集与基于寄存器的指令集

Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

与之相对的另外一套常用的指令集架构是基于寄存器的指令集。

基于栈&基于寄存器:计算“1+1”

基于栈的指令集:主要优点是可移植,主要缺点是执行速度稍慢

8.5.3 基于栈的解释器执行过程

for ecample:

8.6 小结

分析了如何找到正确的方法,如何执行方法内的字节码,以及执行代码时涉及的内存结构。

9 类加载及执行子系统的案例与实战

...

10 前期编译与优化

11 后端编译与优化

12 Java内存模型与线程

并发处理的广泛应用是Amdahl定律nb的根本原因

12.1 概述

CPU实力与其他资源实力悬殊,但不能让CPU闲着,所以要让它多干活,同时处理多项任务。

每秒事务处理数(Transactions Per Second,TPS),是衡量一个服务性能重要的指标之一,它代表着一秒内服务端平均能响应的请求总数。

12.2 硬件的效率与一致性

高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但也引入了一个新问题:缓存一致性(CacheCoherence)。

为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议操作读写:MSI、MESI、MOSI、Synapse、Firefly、DragonProtocol等。

“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

12.3 Java内存模型

12.3.1 主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。

每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据.

12.3.2 内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的:

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

12.3.3 对于volatile型变量的特殊规则

volatile可以说是Java虚拟机提供的最轻量级的同步机制.

volatile变量具备两项特性:

  • 保证此变量对所有线程的可见性
  • 禁止指令重排序优化

12.3.4 针对long和double型变量的特殊规则

允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”.

(规范虽然允许这样做,但多数虚拟机还是保证了long、double的原子性操作)

12.3.5 原子性、可见性与有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的.

1.原子性(Atomicity):要么都执行要么都不执行:read、load等8个原子性操作,以及同步代码块(synchronized)都保证了原子性.

2.可见性(Visibility): 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改.

3.有序性(Ordering): volatile和synchronized两个关键字来保证线程之间操作的有序性

12.3.6 先行发生原则

12.4 Java与线程

12.4.1 线程的实现

线程是比进程更轻量级的调度执行单位.

实现线程主要有三种方式:

1.使用内核线程实现(1:1实现)

使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如图12-3所示。

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。轻量级进程也具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。

2.使用用户线程实现(1:N实现)

使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点。

用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。

劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的

3.使用用户线程加轻量级进程混合实现(N:M实现)。

内核线程与用户线程一起使用的实现方式,被称为N:M实现.

既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。

操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。

12.4.2 Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式线程调度和抢占式线程调度:

  • 协同式调度: 自己执行完了通知别人
  • 抢占式线程调度: 由系统给每个线程分配执行时间,可以设置线程优先级

12.4.3 状态转换

Java语言定义了6种线程状态:

  1. 新建(New):创建后尚未启动的线程处于这种状态。
  2. 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
  3. 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
    1. 没有设置Timeout参数的Object::wait()方法;
    2. 没有设置Timeout参数的Thread::join()方法;
    3. LockSupport::park()方法。
  4. 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
    1. Thread::sleep()方法;
    2. 设置了Timeout参数的Object::wait()方法;
    3. 设置了Timeout参数的Thread::join()方法;
    4. LockSupport::parkNanos()方法;
    5. LockSupport::parkUntil()方法。
  5. 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  6. 结束(Terminated):已终止线程的线程状态,线程已经结束执行

12.5 Java与协程

12.6 小结

介绍Java内存模型,在此基础上介绍线程。

13 线程安全与锁优化

13.1 概述

如何保证并发的正确性、如何实现线程安全?

13.2 线程与安全

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

13.2.1 Java语言中的线程安全

Java语言中各种操作共享的数据分为以下五类:

  1. 不可变: final 修饰的变量(需保证对象自身不可变)
  2. 绝对线程安全:管运行时环境如何,调用者都不需要任何额外的同步措施(实现条件苛刻)
  3. 相对线程安全: 通常意义上所讲的线程安全,类似一些Vector、HashTable等
  4. 线程兼容:指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
  5. 线程对立:无法在多线程环境中并发使用代码(只要并发就有问题)

13.2.2 线程安全的实现方法

1.互斥同步

最常见最主要的手段:在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用。

临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。

synchronized、ReentrantLock

2.非阻塞同步

互斥同步主要问题是线程阻塞和唤醒的性能开销。

基于冲突检测的乐观并发策略:先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施。

CAS指令需要有三个操作数,分别是内存位置、旧的预期值、准备设置的新值。

3.无同步方案

方法不涉及共享数据,自然不需要同步措施保证安全。

13.3 锁优化

锁优化技术:

  1. 适应性自旋(AdaptiveSpinning)
  2. 锁消除(Lock Elimination)
  3. 锁膨胀(Lock Coarsening)
  4. 轻量级锁(Lightweight Locking)
  5. 偏向锁(Biased Locking)

13.3.1 自旋锁与自适应自旋

避免内核创建消毁性能问题,如果同步等待的时间不长,不如就让等待的线程一直忙循环(自旋,while...),这项技术就是所谓的自旋锁

13.3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

13.3.3 锁粗化

大多数情况是对希望锁粒度越小越好,但如果模块代码执行频繁,不如直接在外层大代码块加锁。

13.3.4 轻量级锁

HotSpot虚拟机的对象头分为两部分,第一部分存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,称为“Mark Word”。是实现轻量级锁和偏向锁的关键。

对象未被锁定的状态下,Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。

对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态。

轻量级锁的工作过程:

1.在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位01),虚拟机将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(用于锁释放后还原对象Mark Word)。

2.虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图13-4所示。

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。

如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

上面描述的是轻量级锁的加锁过程,它的解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

13.3.5 偏向锁

目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。如果读者理解了前面轻量级锁中关于对象头Mark Word与线程之间的操作过程,那偏向锁的原理就会很容易理解。

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。

13.4 小结

线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,虚拟机为实现高效并发所做的一系列锁优化措施。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值