深入理解Java虚拟机(10-13)学习总结

Posted 月亮的-影子

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解Java虚拟机(10-13)学习总结相关的知识,希望对你有一定的参考价值。

:本文参考学习周志明老师的《深入理解Java虚拟机(第3版)》

第10章 前端编译与优化

10.1 概述

  • 前端编译器就是把* .java文件转变成*.class文件过程。也可能是JIT运行期把字节码转变为本地机器码的过程。
  • 前端编译器:JDK的javac、Eclipse JDT的增量式编译器
  • 即时编译器:HotSpot的C1和C2编译器、Graal编译器。
  • 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET

10.2 Javac编译器

10.2.1 Javac的源码与调试

  • Javac后期被挪到了jdk.compiler模块

  • javac编译过程分为一个准备过程和3个处理过程

    • 准备过程:初始化插入式注解处理器
    • 解析与填充符号
      • 词法、语法分析
      • 填充符号表
    • 插入式注解处理器处理过程。
    • 分析与字节码生成过程
      • 标注检测
      • 数据流及控制流分析。对程序对动态运行过程分析
      • 解语法糖,简化代码编写的语法糖还原原来的形式
      • 字节码生成。把前面步骤的信息转换成字节码。
  • javac编译的过程。

javac的编译入口

  • JavaCompiler类完成编译。上面的动作集中在compile()和compile2()

10.2.2 解析与填充符号表

  • parseFiles()就是语法分析和词法分析。

1.词法、语法分析

  • 词法分析是把源代码的字符流转变为标记集合的过程。单个字符是最小的程序编写单位,标记才是编译的最小的元素。关键词,字面量,运算符都是标记。通过Scanner类实现。int a=1,分为int、a和1.
  • 语法分析根据标记序列抽象为语法树。

2.填充符号表

  • enterTrees(),符号表由一组符号地址和符号信息构成的数据结构。
  • 符号表登记的信息编译不同阶段需要用到。通过Enter类实现。

10.2.3 注解处理器

  • 插入式注解处理器可以看做是编译器的插件。插件工作允许读取,修改,添加抽象语法树的元素。
  • 有了编译器注解处理标准API,程序员才能干涉编译器的行为。比如Lombok。

10.2.4 语义分析与字节码生成

  • 经过语法分析,编译器获得了程序代码的抽象语法树的表示。抽象语法树可以表示结构正确的源程序。但是无法表示源程序的语义是符合逻辑的。
  • 语义分析根据结构上下文检查语义。
  • 编码的红线标注错误就是语义分析的结果。

1.标注检查

  • 语义分析分为标注检查和数据及控制流分析
    • 标注检查需要检查变量使用前是否被声明。变量、赋值之间的数据类型是否能够匹配。
    • 还有常量折叠优化。比如a=1+2,优化之后就直接是3了。

2.数据及控制流分析

  • 数据流分析和控制流分析是对程序的上下文逻辑进一步验证。
  • 检查局部变量使用前是否赋值,方法每条路径是否有返回值,是否所有受查异常都被正常处理。
  • 可以看做和类的加载时的数据与控制流的分析目的是一样的。但是校验范围不同。

3.解语法糖

  • 语法糖可以减少代码量。增加程序的可读性。减少程序出错的机会。
  • 常见的语法糖
    • 泛型。
    • 变长参数
    • 自动装箱拆箱。
  • Java虚拟机并不是直接支持这些语法的。他们会在编译的阶段还原回原始的基础语法结构。这个就是解语法糖。

4.字节码生成

  • Javac的最后一个阶段。包括生成字节码。

10.3 Java语法糖的味道

10.3.1 泛型

  • 泛型的本质是参数化类型和参数化多态的应用。

1.Java与C#的泛型

  • Java选择泛型的实现是类型擦除式泛型。C#实现的是具体化式泛型。
  • C#的List< string >和List< int >有自己独立的虚方法表,和类型数据。
  • Java语言的泛型。只在源码存在,编译后的字节码文件中,泛型都被替换了。

3.类型擦除

  • 类型擦除需要裸类型,它是所有的泛型化实例的共同父类型。
  • 实际上就是从Hashmap< String>到HashMap,也就是插入元素的时候,插入的时候从Object转换为String。
  • 泛型遇到重载的时候,就会发现是不行的。原因就是由于类型擦除导致参数看上去不同,当编译之后其实就是一样的了。

10.3.2 自动装箱、拆箱与遍历循环

  • 这是编译之前的。
public static void main(String[] args) 
    List<Integer> list = Arrays.asList(1, 2, 3, 4);
    int sum = 0;
    for (int i : list) 
        sum += i;
    
    System.out.println(sum);

  • 编译之后的装箱补充。
public static void main(String[] args) 
    List list = Arrays.asList( new Integer[] 
            Integer.valueOf(1),
            Integer.valueOf(2),
            Integer.valueOf(3),
            Integer.valueOf(4) );
    int sum = 0;
    for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) 
        int i = ((Integer)localIterator.next()).intValue();
        sum += i;
    
    System.out.println(sum);

第11章 后端编译与优化

11.1 概述

  • 编译器无论在何时、在何种状态把Class文件转换与本地基础设施相关的二进制机器码,这个就可以视为编译过程的后端。
  • 字节码只是程序语言的中间表达过程。

11.2 即时编译器

  • JIT是为了解决热点代码等的优化。把代码编译为本地机器码,而不是以前那样看一句解释一句。
  • 问题
    • 为何HotSpot虚拟机要使用解释器与即时编译器并存的架构?
    • 为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?
    • 程序何时使用解释器执行?何时使用编译器执行?
    • 哪些程序代码会被编译为本地代码?如何编译本地代码?
    • 如何从外部观察到即时编译器的编译过程和编译结果?

11.2.1 解释器与编译器

解释器与编译器各自的优势

  • 程序需要迅速启动和执行的时候,解释器首先发挥作用,省去编译的时间,立刻运行。
  • 但是程序运行之后,编译器的作用是把代码编译为本地代码,减少解释器的中间损耗。

即时编译器的类型

  • HotSpot通常是内置了两个到三个即时编译器,分别称为客户端编译器和服务器端编译器。
  • 也就是C1和C2编译器。
  • 通常是解释器与即时编译器混合工作。如果不想那么可以通过
    • -Xint强制虚拟机只能够运行解释模式
    • -Xcomp可以强制虚拟机运行编译模式。

即时编译器的分层

  • 由于JIT编译需要时间,而且需要解释器收集监控信息,毫无疑问是非常损耗性能的
  • 所以需要分层编译功能。
    • 第0层,纯解释,解释器不开启性能监控
    • 第1层,客户端把字节码编译为本地代码运行。不开启性能监控
    • 第2层:客户端编译器+仅开启方法和回边次数统计有限的性能监控功能。
    • 第3层:客户端编译器+开启全部的性能监控功能。还会收集分支跳转、虚方法调用等。
    • 第4层:使用服务器端,把字节码编译为本地代码,它能够开启更多优化,但是可能会激进优化。
  • 使用客户端编译器可以获取更高的编译速度,但是服务器端的编译器能够有更好的编译质量。
  • 通常需要-XX:TieredCompilation来开启分层编译。

11.2.2 编译对象与触发条件

  • 热点代码
    • 多次调用的方法
    • 被多次执行的循环体。

栈上替换的定义。

  • 对于两种情况,编译目标是整个方法体而不是循环体。
    • 第一种情况编译整个方法
    • 后一种也是编译整个方法。只是执行入口(从第几条字节码开始执行)不同。编译的时候传入执行入口字节码序号(Byte Code Index,BCI)这种编译在方法执行过程中,可以被称为栈上替换。方法的栈帧还在栈上,但是方法被替换了。意思就是方法的执行入口已经是开始执行编译器编译好的代码。而不是解释执行了。方法在栈,但是方法被编译器替换,所以是栈上替换。
  • 那么怎么才算是被执行多次?是不是要即时编译。这个行为就是热点探测。
    • 采样法。周期检测线程的调用栈顶。缺点是很难知道方法的热度
    • 计数器,统计执行方法的执行次数。维护很难,不能直接获取方法的调用关系。
    • 虚拟机使用的是基于计数器的热点探测。所以Hotspot配备了方法调用计数器和回边计数器。如果计数器溢出就会触发即时编译。

热点探测的计数器

  • 方法调用计数器,统计方法执行次数。阈值可以通过-XX:CompileThreshold来调整,并且调用方法的时候首先看看方法是不是被即时编译过,如果是,优先使用本地代码。如果超过阈值也会发送编译请求。执行引擎默认不会等待,而是继续解释执行。知道方法的入口地址被系统修改。为新值。
  • 方法调用计数器只是计算一段时间的,如果一段时间没有达到阈值就会减少一半,这个就是半衰周期。在垃圾回收的同时一起执行。可以使用虚拟机参数-XX:UseCounterDecay关闭热度衰减。
  • 还可以使用-XX:CounterHalfLifeTime参数设置为半衰的周期时间。

回边计数器

  • 统计方法体循环体的执行次数。字节码遇到控制流向后跳转的指令就是回边。为了触发栈上的替换编译。
  • 阈值设置-XX:CompileThreshold的参数-XX:BackEdgeThreshold。
  • 但是用户需要设置另一个参数-XX:OnStackReplacePercentage调整回边计数器的阈值。
  • 解释器遇到一条回边指令的时候,检查是不是有编译好的版本,如果有那么就优先使用。否则就是回边计数器+1。超过阈值的时候就发送栈上替换的请求。并且调整回边计数器的阈值。
  • 回边计数器没有衰减的过程。而且是绝对次数。

11.2.3 编译过程

  • 虚拟机在没有完成编译的时候都是解释执行。编译动作在后台完成。
  • 也可以通过-XX:BackgroundCompilation禁止后台编译。那么虚拟机继续执行就必须阻塞等待编译。

那在后台执行编译的过程中,编译器具体会做什么事情呢?

  • 客户端关注的是局部优化,放弃耗时比较长的全局优化。而且他是三阶段编译器
    • 第一个阶段,独立前端把字节码构成一种高级中间代码表示(HIR),HIR使用静态分配代表代码值。
    • 第二个阶段:后端从HIR中产生低级的中间代码表示(LIR),在这之前会优化HIR,空值消除等,范围检查。
    • 最后的阶段就是后端的线性扫描,在LIR分配寄存器,并在LIR做窥孔优化,产生机器码。

  • 服务器端的编译采用的寄存器是全局图着色分配器。能够充分利用处理器的架构。它的编译很慢。

11.2.4 实战:查看及分析即时编译结果

  • 下面是测试代码,需要加上-XX:+PrintCompilation,如果有%说明进行了栈上分配。
  • 这里的代码逻辑实际上都内联到了main方法里面。
public class MyTest 
    public static final int NUM = 15000;
    public static int doubleValue(int i) 
// 这个空循环用于后面演示JIT代码优化过程
        for(int j=0; j<100000; j++);
        return i * 2;
    


    public static long calcSum() 
        long sum = 0;
        for (int i = 1; i <= 100; i++) 
            sum += doubleValue(i);
        
        return sum;
    

    
    public static void main(String[] args) 
        for (int i = 0; i < NUM; i++) 
            calcSum();
        
    

11.3 提前编译器

11.3.1 提前编译的优劣得失

  • 提前编译的两个分支
    • 程序运行之前把代码编译成机器码的静态翻译工作
    • 另一条分支是把原本即时编译器在运行要做的编译工作提前做好并且保存下来。下次用的时候加载进来使用。
  • 第一条是传统的提前编译应用形式,JIT始终还是会占用运算资源和运算的时间。
  • 第二条路径,本质就是给即时编译器做一个缓存加速。这种是动态提前编译。即时编译缓存。

即时编译器的优势

  • 性能分析制导优化,通过收集性能数据来优化,能够更好地分配资源。
  • 激进预测性优化
  • 链接时优化。

11.4 编译器优化技术

11.4.1 优化技术概览

  • 下面的代码看上去已经很简洁了,但是仍有优化的空间。
    • 第一个是方法内敛,去除方法调用创建栈帧的成本
static class B 
    int value;
    final int get() 
        return value;
    

public void foo() 
    y = b.get();
    // ...do stuff...
    z = b.get();
    sum = y + z;


  • 这个就是优化之后的代码。
public void foo() 
    y = b.value;
    // ...do stuff...
    z = b.value;
    sum = y + z;

  • 第二步就是访问冗余消除。
  • 这样的好处就是不需要访问对象b的局部变量。
public void foo() 
    y = b.value;
    // ...do stuff...
    z = y;
    sum = y + z;


  • 第三步复写传播,很明显就是z和y相同。那么用y代替z。
public void foo() 
    y = b.value;
    // ...do stuff...
    y = y;
    sum = y + y;


  • 第四次优化是无用代码消除。这里的y=y没有任何意义。
public void foo() 
y = b.value;
// ...do stuff...
sum = y + y;


11.4.2 方法内联

  • 方法内联可以减少方法调用带来的成本。为其它优化手段建立良好的基础。如果不做内敛可能就无法进行进一步的优化,可以参考上面的例子。
  • 无法内敛的原因
    • invokespecial调用私有方法、实力构造器、父类方法和invokestatic调用的静态方法才会在编译期解析。
    • 其它必须是虚方法,虚方法很难决定哪个是正确的方法版本。
  • 为了解决虚方法问题,Java虚拟机引入了类型继承关系分析技术。
    • 用于确定类的某个接口有多于一种的实现。
    • 某个类是不是存在子类覆盖父类的某个虚方法。
    • 如果不是虚方法直接内联
    • 如果是通过CHA也就是类型继承关系分析技术来查询。
      • 如果发现只有一种状态的实现,那么这种就是守护内联
      • 否则就是激进的预测性优化,需要逃生门。也就是预测失败的退路。
  • 或者另外一种方式就是内联缓存。减少方法调用的开销。
    • 工作原理是第一调用方法会记录方法接受者的版本,然后每次调用都比较版本。如果版本一样那么就是单态内联缓存。
    • 如果发现版本不同,就会退化为多态内联缓存。还是要通过查找虚方法表来进行分派。

11.4.3 逃逸分析

  • 基本原理是分析对象的动态作用域。当一个对象在方法定义好之后,被外部方法调用。比如作为参数传递到别的方法,这种就是方法逃逸。还有可能被外部线程访问,这种是线程逃逸。
  • 根据逃逸程度的不同优化介绍
    • 栈上分配:如果确定对象不会逃逸出线程之外,那让对象在栈上分配也会是不错的选择,因为对象占用的内存,随着栈帧出栈销毁。由于对象在堆空间的回收消耗大量的资源。所以才会提出栈上分配。栈上分配支持方法逃逸,但是不支持线程逃逸。只要不逃出线程之外,那么就可以使用栈上分配。
    • 标量替换:数据不能再拆分,比如基础类型int和long这些就是标量。如果可以拆解那么就是聚合量。对象就是聚合量。根据程序情况,把对象的成员变量恢复原始类型访问,这个就是标量替换。如果对象不被方法外部访问,而且对象可以拆散,那么程序执行的时候,不去创建对象,而是创建方法使用的成员变量代替。这样能够为后续优化提供条件。它是不允许对象逃逸到方法之外的。
    • 同步消除:变量如果不会逃逸出线程,那么这个变量的读写肯定不会有竞争。所以这个变量的同步措施可以消除。

逃逸分析工作过程

// 完全未优化的代码
public int test(int x) 
    int xx = x + 2;
    Point p = new Point(xx, 42);
    return p.getX();
	
  • 第一步P的构造函数和getX()内联优化。
// 步骤1:构造函数内联后的样子
public int test(int x) 
    int xx = x + 2;
    Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
    p.x = xx; // Point构造函数被内联后的样子
    p.y = 42
    return p.x; // Point::getX()被内联后的样子

  • 第二步经过逃逸分析,发现test()范围对象实例p不会逃逸。那么进行一个标量替换。
  • 实际上就把p.x和p.y变成普通的基础变量。
// 步骤2:标量替换后的样子
public int test(int x) 
    int xx = x + 2;
    int px = xx;
    int py = 42
    return px;

  • 第三步根据数据流分析,py值对方法不造成影响,所以无效代码消除优化。
// 步骤3:做无效代码消除后的样子
public int test(int x) 
return x + 2;

开启逃逸分析

  • -XX:DoEscapeAnalysis开启逃逸分析。
  • -XX:+EliminateAllocations:开启标量替换
  • -XX:+EliminateLocks:同步消除

11.4.4 公共子表达式消除

  • 表达式E被计算过,而且E的所有变量值没有改变,那么E的出现就是公共子表达式。没有必要重新计算。

int d = (c * b) * 12 + a + (a + b * c);

iload_2 // b
imul // 计算b*c
bipush 12 // 推入12
imul // 计算(c * b) * 12
iload_1 // a
iadd // 计算(c * b) * 12 + a
iload_1 // a
iload_2 // b
iload_3 // c
imul // 计算b * c
iadd // 计算a + b * c
iadd // 计算(c * b) * 12 + a + a + b * c
istore 4
  • 上面没有做任何优化的代码。
  • 如果加入了即时编译器。检测到b*c和c *b是一个表达式那么就会优化为int d = E * 12 + a + (a + E);
  • 还能够代数化简int d = E * 13 + a + a;能够节省很多的计算时间。减少了计算的指令。

11.4.5 数组边界检查消除

  • 对于每次数组元素访问都需要一次越界判断是非常消耗性能的。
  • 但是数组检测越界是不是每次都要检测?
  • 可以通过对数据流的分析循环访问,如果在合理范围那么就不会进行越界检测。
  • 空指针检测和除数是0的检测采用了隐式的异常处理。
if (foo != null) 
    return foo.value;
    else
    throw new NullPointException();


  • 经过处理之后
try 
    return foo.value;
     catch (segment_fault) 
    uncommon_trap();


  • 虚拟机会注册一个Segment Fault信号异常处理器。当foo是空的时候才会转到异常处理器中恢复中断并且抛出异常。

第12章 Java内存模型与线程

12.2 硬件的效率与一致性

  • 高速缓存解决了处理器与内存之间的矛盾。但是带来了新的问题:缓存一致性。

12.3 Java内存模型

  • Java内存模型的作用是屏蔽各种硬件和操作系统的内存访问差异,并且实现Java程序的各个平台能够达到一致的内存访问效果。

12.3.1 主内存与工作内存

  • 工作内存是线程独有的。
  • 主内存是线程共享的。

12.3.2 内存间交互操作

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量 才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以 便随后的load动作使用。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以 便随后的load动作使用。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚 拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随 后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的 变量中。
  • 如果要把主内存拷贝到工作内存就要按照一定的顺序。read和load。store之后才能write。
  • Java内存模型的八个规则
    • 不允许read、load和store、write调换位置。
    • 不允许线程丢弃最近的assign行为。
    • 不允许线程没有发生assign的时候把工作内存同步到主内存。
    • 新变量只能在主内存诞生。
    • 一个变量同一时刻只能被一个线程lock。
    • 变量必须lock之后才能unlock
    • 对变量unlock必须要同步到主内存。

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

  • 两项特性
    • 可见性
    • 有序性,禁止指令重排序。
  • volatile修饰的变量的赋值多了一个lock addl$0x0,(%esp)操作。相当于就是一个内存屏障。重排序的时候不能够把
  • lock的作用是把缓存写入到内存。写入的同时也会把别的内核缓存无效化。也就是Java内存模型的store和write操作。并且这样的空操作可以让volatile变量的修改对其他的处理器可见。
  • 为什么可以禁止指令重排序?lock指令能够保证之前的操作一定是完成的。指令重排序是无法越过这条指令的。
  • volatile读操作和普通的变量读差不多。但是写操作会慢一些。因为需要插入很多的内存屏障保证处理器不乱序执行。但是volatile总开销还是要少一些。

案例。

  • T是一个线程,V和W分别是volatile变量。进行read、load、use、assign、store、write操作需要满足的规则。
    • T对V变量前一个操作是load的时候,线程T才能够对变量V进行use动作。并且只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对V进行load操作。use可以说一定要load和read是一起出现的。
      • 也就是上面的规则必须要让使用V之前,必须从主内存刷新最新的值。保证对其他线程可见。
    • 只有线程T对变量V执行的前一个操作是assign的时候,线程T才能够对变量V执行store操作。反之一样。也就是assign是线程T对V的store、write动作相关联的。必须连续三个出现。
      • 上面这条规则保证了每次修改V之后必须同步会主内存。保证其他线程对变量V的修改。
    • 假定动作A是线程T对变量T执行的assign或者是use动作。动作F是A相关联的load或者是store动作。假定动作P是动作F相对应的V的read和write操作。动作B是线程T对变量W的执行的use或者是assing操作。假定动作G是和动作B相关联的load或store动作。假定动作Q是动作G的相应的变量W的read或者是write操作。如果A先于B,那么P一定是先于Q的。
      • 这条规则要求volatile变量不会被指令重排序。保证代码的执行顺序与程序顺序相同。

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

  • Java内存模型要求lock、unlock、read、load、assign、use、store、write有原子性。
  • 但是对于64位数据类型来说。允许没有被volatile修饰的变量可以划分两次32位操作执行。也就是允许虚拟机可以自行选择是否保证load、store、read、write的原子性。也就是long和double的非原子性的协定。

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

  1. 原子性
  • Java内存模型直接保证的原子性是read、load、assign、use、store、write。也就是基本数据类型的访问和读写都是具备原子性的。
  • 更大范围的原子性可以是lock或者unlock完成。
  1. 可见性(Visibility)
  • 可见性就是线程修改共享变量之后,其它线程可以立即得知这个修改。
  • Java内存模型通过修改后把新的值同步回主内存。在变量读取之前必须从主内存读取。
  • 同步块的可见性对变量执行unlock之前,必须把变量同步到主内存。(store、write)这条规则获得的。
  • final关键词修饰的字段的可见性是构造器初始化完成,构造器没有把this的引用传递出去。避免只看到一半的初始化。
  1. 有序性(Ordering)
  • Java内存模型的天然有序性,线程内的操作有序。但是从另一个线程看另一个线程操作是无序的。
    • 前半句是线程内表现类似串行。
    • 后半句是指令重拍序与工作内存与主内存延迟的现象。

12.3.6 先行发生原则

  • 这个原则是判断数据是否存在竞争。线程是否安全的手段。
  • 先行发生指的是Java内存模型定义的两项操作之间的偏序关系。操作A比操作B先行发生。在操作B之前操作A产生的影响可以被操作B观察到。
  • Java内存模型的天然先行关系。
    • 程序次序规则:就是按照线程内的控制流顺序执行。
    • 管程锁定规则:同一个锁unlock在lock之前。
    • volatile变量规则:volatile的写操作先行于后面变量的读操作
    • 线程启动规则:Thread的start方法先行于线程内的所有动作。
    • 线程终止规则:线程的所有操作先行于线程的终止检测。
    • 线程中断规则:对线程的interrupt方法调用先行于被中断的线程的代码检测中断事件的发生。
    • 对象终结规则:一个对象初始化完成先行于它的finalize方法。
    • 传递性:操作A先行于操作B。操作B先行于操作C。可以得出A也先行于C。

12.4 Java与线程

12.4.1 线程的实现

  • Java语言的线程都是统一使用Thread。已经调用过start的Thread就代表一个线程。所有方法都是native。
  • native方法意味着方法无法使用平台无关的手段实现
  • 实现线程的方式
    • 内核线程
    • 用户级线程
    • 用户线程+轻量级进程。
  1. 内核线程实现
  • 内核线程的实现方式就是1:1,内核线程是操作系统直接支持的线程。内核来切换线程。把线程的任务映射到处理器上。
  • 程序一般不使用内核线程,而是使用内核线程的一种高级接口轻量级进程(LWP)。每个轻量级进程都由一个内核线程支持。
  • 由于内核线程的支持,轻量级进程在系统调用被阻塞的时候不会影响进程的继续工作。
  • 轻量级进程的问题
    • 用户态和和心态的来回切换。
    • 每个轻量级的进程消耗一定的内核资源。

  1. 用户线程实现
  • 用户线程是1:N。一个线程不是内核线程肯定就是用户级线程。轻量级进程也属于用户线程。但是轻量级进程建立在内核之上,很多操作都需要系统调用。
  • 系统内核无法感知用户线程的存在。用户线程的切换,销毁都在用户态。线程切换可能不需要切换到内核态。操作快速而且低消耗。
  • 但是用户线程没有系统内核的支援,所有的线程操作都是用户程序自己处理。程序的创建,销毁,切换和调度都是用户需要考虑的。
  • 操作系统只会把处理器资源分配给进程,也就是阻塞如何处理还有多线程处理系统如何把线程映射到处理器也是一个问题。

  1. 混合实现
  • 内核线程与用户线程一起使用。存在用户线程+轻量级线程。操作系统支持轻量级的进程作为用户线程和核心线程的桥梁。能够通过内核支持调度和处理器的映射。用户线程的系统调用可以通过轻量级进程来完成。

  1. Java线程的实现
  • Java虚拟机线程模型是基于操作系统原生的线程模型1:1实现。
  • HotSpot每一个Java线程都是直接映射到操作系统的原生线程实现。HotSpot不会干扰线程的调度。
  • Solaris平台的HotSpot虚拟机支持1:1和N:M的线程模型。

12.4.2 Java线程调度

  • 线程调度的方式
    • 协同式
    • 抢占式。
  • 协同式的好处就是线程把事情干完之后才切换。切换对线程自己是可知的。但是线程的执行时间不可控,如果线程出问题,不告知系统导致一直不切换。
  • 抢占式。线程的时间通过系统分配。

12.5 Java与协程

12.5.1 内核线程的局限

  • Java并发机制与多个分布式服务的快速执行计算出结果出现了矛盾。
  • 1:1的内核线程模型切换的缺点就是切换与调度的成本太高。系统容纳的线程数量有限。在现在请求需要执行时间变短,处理请求变多的情况下,用户线程的切换开销可能接近计算的开销。

12.5.2 协程的复苏

  • 内核线程切换导致的开销为什么这么大?
  • 内核线程的调度成本主要来自于用户态和核心态之间的状态切换。状态切换的开销是响应中断,保护和恢复执行现场的成本。
  • 那么切换成用户线程是不是就解决问题了?
  • 答案是不能,但是把保护和恢复现场交给程序员,那么有机会减少这些开销。
  • 最初的用户线程是协同式调度,所以别名是协程。协程可以完整调用栈保护和恢复工作。
  • 协程比较轻量级。实现在用户态的切换与保护。
  • 协程的局限是应用层实现的内容很多。

12.5.3 Java的解决方案

  • 有栈协程的一种实现是纤程。做线程保护,恢复和纤程调度。

第13章 线程安全与锁优化

13.2 线程安全

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

13.2.1 Java语言中的线程安全

  1. 不可变
  • 不可变的对象一定是线程安全的。
  • 只要一个不可变对象被正确构建,那么外部的可见性永远不会变化。
  • Java语言的基本数据类型只要是final修饰就能够保证不可变。
  • 如果是对象本身,必须保证自身的变量不可变。
  1. 绝对线程安全
  • Vector虽然方法都是同步方法,但是不代表两个方法的分别调用是能够线程安全的
  1. 相对线程安全
  • 保证单次操作线程安全就可以了。
  1. 线程兼容
  • 线程兼容指可以通过同步手段来保证对象在并发环境可以安全使用。
  1. 线程对立
  • 线程对立就是无论是否采取了同步手段,都无法在多线程里面并发使用代码。

13.2.2 线程安全的实现方法

  1. 互斥同步
  • 同步保证共享数据在同一个时刻只被一个线程调用。
  • 互斥是实现同步的一个手段,临界区,互斥量,信号量都是互斥的常见实现。
  • 互斥是方法,同步是目的。
  • Java语言的同步互斥就是synchronized关键字实现。在经过编译之后会在同步块前后生成monitorenter和monitorexit。两个字节码指令都需要一个reference参数来指明锁定和解锁的对象。
  • monitorenter首先需要尝试获取对象的锁。如果获取成功锁的计数器+1。monitorexit就会-1。计数器只要是0,那么锁就会被释放了。
  • synchronized是可重入的。
  • synchronized在持有锁的线程执行完毕释放锁之前,无条件阻塞后面的线程。
  • 持有锁是一个重量级的操作。
  • 后面通过了Lock接口实现了互斥同步。
  1. 非阻塞同步
  • 互斥同步的问题是线程阻塞和唤醒带来的代价。这种同步也可以说是阻塞同步。互斥同步是一个悲观的并发策略,无论数据是否共享都会加锁。
  • 后来可以实现乐观的并发策略。可以先进行操作,如果没有线程争用共享数据那么操作直接成功。如果失败就重试。
  1. 无同步方案
  • 同步是保证存在共享数据争用正确的手段。
  • 不需要同步措施
    • 可重入代码。代码执行可以被中断,转去执行别的代码。控制权回来之后原来的程序不会出错。
    • 线程本地存储。也就是数据无需共享。

13.3 锁优化

13.3.1 自旋锁与自适应自旋

  • 互斥同步对性能最大的影响是阻塞,挂起,恢复线程等。
  • 所以后来引入了请求锁的线程可以自旋,线程等一等,看看能不能获取锁。进入忙等待。
  • 它占用处理器时间,所以自旋时间需要限制。

13.3.2 锁消除

  • 同步块可能会被消除。主要根据逃逸分析。

13.3.3 锁粗化

  • 把同步块范围变大。如果锁加载循环体里面,而且没有线程竞争就会导致性能的消耗很大。

13.3.4 轻量级锁

  • 没有多线程的竞争下,减少重量级锁的使用操作系统的互斥量产生的性能消耗。
  • 轻量级锁的工作流程
    • 虚拟机在当前线程的栈帧创建一个锁记录空间,存储对象的Mark Word。并且把对象的Mark Word更新为Lock Record。表示线程已经拥有了这个锁。Mark Word的锁标志位变成了00。
    • 如果更新失败,先检查对象的Mark Word是否指向线程的栈帧,如果不是那么就要膨胀为重量级锁。锁标志变为10。Mark Word指向了重量级锁。
    • 解锁也是CAS。如果发现对象的Mark Word还是指向线程的记录,那么就CAS替换回来。如果失败说明有别的线程在尝试获取锁,释放锁的同时唤醒下一个线程。

13.3.5 偏向锁

  • 它的目的是无竞争的状态下的同步原语。提升程序的性能。 偏向锁在无竞争的情况下把同步都消除了, CAS不需要处理。
  • 它会偏向第一个获取它的线程。
  • 偏向锁的工作原理
    • CAS操作把获取锁的线程的ID记录在对象的Mark Word。CAS成功的话持有偏向锁的线程进入同步块,虚拟机不需要进行同步操作。
    • 一旦有竞争,偏向模式结束。根据对象目前是否处于锁定状态来决定是否撤销偏向。撤销标志位为未锁定或者是轻量级锁定状态。
    • 线程ID占用了之前的Mark Word的hashcode。也就是获取hashcode之后就无法使用偏向锁了。

以上是关于深入理解Java虚拟机(10-13)学习总结的主要内容,如果未能解决你的问题,请参考以下文章

深入理解Java虚拟机 学习总结

JVM内存结构---《深入理解Java虚拟机》学习总结

深入理解Java虚拟机--个人总结(持续更新)

深入理解JAVA虚拟机读书笔记——调优案例分析总结

深入理解Java虚拟机总结

JVM学习资料