一文汇总JVM所有知识点(二)

404 阅读1小时+

平时看博客或者学知识,学到的东西比较零散,没有独立的知识模块概念,而且学了之后很容易忘。于是我建立了一个自己的笔记仓库 (一个我长期维护的笔记仓库,感兴趣的可以点个star~你的star是我写作的巨大大大大的动力),将平时学到的东西都归类然后放里面,需要的时候呢也方便复习。

8. 前端编译与优化

8.1 概述

编译期没有具体的上下文语境的话,其实是一个模糊的表述。它可能是下面3种情况:

  • 前端编译器(叫“编译器的前端“更准确)把.java文件转换成.class文件的过程
  • Java虚拟机的即时编译器(JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程
  • 使用静态的提前编译器(AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程

Java虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是由javac产生的class文件(如JRuby、Groovy等语言的class文件)也同样能享受到编译器优化措施所带来的性能红利。

相当多新生的Java语法特性,都是靠编译器的语法糖来实现的,而不是依赖字节码或者Java虚拟机的底层改进来支持。

8.2 javac编译器

8.2.1 javac的编译过程

大致可以分成4个步骤:

  1. 准备过程:初始化插入式注解处理器
  2. 解析与填充符号表过程
    • 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树
    • 填充符号表。产生符号地址和符号信息
  3. 插入式注解处理器的注解处理过程
  4. 分析与字节码生成过程
    • 标注检查:对语法的静态信息进行检查
    • 数据流及控制流分析:对程序动态运行过程进行检查

8.2.2 解析与填充符号表

词法、语法分析

词法分析是将源代码的字符流转变为标记集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记。

词法分析是根据标记序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释都可以是一种特定的语法结构。

填充符号表

完成了语法分析和词法分析之后,下一阶段是对符号表进行填充的过程。符号表是由一组符号地址和符号信息构成的数据结构。

8.2.3 注解处理器

JDK6设计了一组被称为插入式注解处理器的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。

有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间(例如:编译时获取注解信息,并据此产生Java代码文件,无性能损失,如ButterKnife、Dagger2等)。

8.2.4 语义分析与字节码生成

经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查等等。

我们在编码时经常能在IDE中看到红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果。

8.2.4.1 标注检查

javac在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤。

标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等等。在标注检查中,还会顺便进行一个称为常量折叠的代码优化,这是javac编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。

int a = 1 + 2;

上面这段代码在抽象语法树上仍然能看到字面量12和操作符+号,但是经过常量折叠优化之后,它们将会被折叠为字面量3。由于编译期间进行了常量折叠,所以在代码里定义a=1+2比起直接定义a=3并不会增加程序运行期哪怕仅仅一个处理器时钟周期的处理工作量。

8.2.4.2 数据及控制流分析

数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受检异常都被正确处理了等问题。

8.2.4.3 解语法糖

语法糖指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。

Java中最常见的语法糖包括:泛型、变长参数、自动装箱拆箱等等。Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程被称为解语法糖。

8.2.4.4 字节码生成

字节码生成是javac编译过程的最后一个阶段。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转换成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。

例如实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段被添加到语法树之中的。

除了生成构造器以外,还有其他的一些代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于或等于JDK5)的append()操作等等。

8.3 Java语法糖

语法糖可以方便程序员的代码开发,虽然它们不会提供实质性的功能改进,但是它们能提高效率。语法糖总体可以看做是前端编译器的一些小把戏,最后还是得还原才能在虚拟机上运行。

8.3.1 泛型

泛型的本质是参数化类型或参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成了泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。

Java选择的泛型实现方式叫作类型擦除式泛型。Java的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型了,并且在相应的地方插入了强制转型代码。因此对于运行期的Java来说,ArrayList<int>ArrayList<String>其实是同一个类型。

下面是Java中不支持的泛型用法:

public class TypeErasureGenerics<E> {
    public void doSomething(Object item) {
        if(item instanceof E) {      //不合法,无法对泛型进行实例判断
            ......
        }
        E newItem = new E();         //不合法,无法使用泛型创建对象
        E[] itemArray = new E[10];   //不合法,无法使用泛型创建数组
    }
}

Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型。那Java为啥要选择擦除式泛型来实现:这里有一点历史原因,为了兼容老版本。Java泛型的唯一优势就在于实现这种泛型的影响范围上,擦除式泛型的实现几乎只需要在javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java5.0之上。

8.3.1.1 类型擦除

裸类型:应该被视为所有该类型泛型化实例的共同父类型,只有这样,像下面代码中的赋值才是被系统允许的从子类到父类的安全转型。

ArrayList<Integer> ilist = new ArrayList<>();
ArrayList<String> slist = new ArrayList<>();
ArrayList list; //裸类型
list = ilist;
list = slist;

下面举个例子来验证一下Java的泛型擦除。

public static void main(String[] args) {
    Map<String,String> map = new HashMap<>();
    map.put("hello", "你好");
    map.put("how are you", "吃了没");
    System.out.println(map.get("hello"));
    System.out.println(map.get("how are you"));
}

把这段代码编译成class文件之后,再用JD-GUI工具将其反编译。将会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从Object到String的强制转型代码。

//反编译出来的代码  泛型擦除
public static void main(String[] args) {
    HashMap map = new HashMap();
    map.put("hello", "你好");
    map.put("how are you", "吃了没");
    System.out.println((String)map.get("hello"));
    System.out.println((String)map.get("how are you"));
}

类型擦除的缺陷

首先,使用擦除法实现泛型直接导致了对原始类型数据的支持又成了新的麻烦。如下:

//原始类型的泛型(目前的Java不支持)
ArrayList<int> ilist = new ArrayList<int>(); 
ArrayList<long> llist = new ArrayList<long>(); 
ArrayList list;
list = ilist;
list = llist;

这种情况下,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持int、long与Object之间的强制转型。当时Java给出的解决方案一如既往的简单粗暴:既然没法转换那就索性别支持原生类型的泛型了吧,都用ArrayList<Integer>ArrayList<Long>,反正都做了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做了。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,成为Java泛型慢的重要原因。

第二,运行期无法取到泛型类型信息,会让一些代码变得想当啰嗦。

//不得不加入的类型参数
public static <T> T[] convert(List<T> list, Class<T> componentType) { 
    T[] array = (T[])Array.newInstance(componentType, list.size()); 
    ...
}

需要注意的是,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

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

自动装箱、拆箱与遍历循环(for-each循环)这些语法糖在Java语言里面是被使用最多的。举个简单例子:

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) {
    //1. 泛型没了
    //2. Arrays.asList()这里是变长参数,变长参数最终是会转换成数组的形式
    List list = Arrays.asList( new Integer[] {
        //3. 自动装箱了
        Integer.valueOf(1),
        Integer.valueOf(2),
        Integer.valueOf(3),
        Integer.valueOf(4) });
    int sum = 0;
    //4. for-each 其实是利用Iterator来进行的遍历
    for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
        //5. 自动拆箱了
        int i = ((Integer)localIterator.next()).intValue();
        sum += i;
    }
    System.out.println(sum);
}

自动装箱好是好,但也有一些陷阱,先来看段代码:

public class Temp {
    public static void main(String[] args) {
        Integer a = 1;
        Integer b = 2;

        //Integer默认是缓存-128到127的实例,有缓存则直接用已有的
        Integer c = 3;
        Integer d = 3;
        
        Integer e = 321;
        Integer f = 321;
        
        Long g = 3L;
        
        System.out.println(c == d); //true  有缓存
        System.out.println(e == f); //false  无缓存
        System.out.println(c == (a + b)); //true 有算术运算,自动拆箱了
        System.out.println(c.equals(a + b)); //true integer3.equals(Integer.valueOf(integer1.intValue() + integer2.intValue()))
        System.out.println(g == (a + b));//true 有算术运算,自动拆箱了
        System.out.println(g.equals(a + b)); //false  类型不一样
    }
}

//编译后
public class Temp {
  public static void main(String[] paramArrayOfString) {
    Integer integer1 = Integer.valueOf(1);
    Integer integer2 = Integer.valueOf(2);
    Integer integer3 = Integer.valueOf(3);
    Integer integer4 = Integer.valueOf(3);
    Integer integer5 = Integer.valueOf(321);
    Integer integer6 = Integer.valueOf(321);
    Long long_ = Long.valueOf(3L);
    System.out.println((integer3 == integer4));
    System.out.println((integer5 == integer6));
    System.out.println((integer3.intValue() == integer1.intValue() + integer2.intValue()));
    System.out.println(integer3.equals(Integer.valueOf(integer1.intValue() + integer2.intValue())));
    System.out.println((long_.longValue() == (integer1.intValue() + integer2.intValue())));
    System.out.println(long_.equals(Integer.valueOf(integer1.intValue() + integer2.intValue())));
  }
}

我已经将打印结果放注释里面了。包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们equals()方法不处理数据转型的关系,建议在实际编码中尽量避免这样使用自动装箱与拆箱。

8.3.3 条件编译

Java语言中并没有预处理器,因为Java天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)就无须使用到预处理器。

曲线救国,Java语言也是可以进行条件编译的,方法就是使用条件为常量的if语句。如下面代码所示,该代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码之中只包括if里面的那条语句,而else里面的就被舍弃掉了。

//java语言的条件编译
public static void main(String[] args) {
    if (true) {
        System.out.println("block 1");
    } else {
        System.out.println("block 2");
    }
}

//编译之后
public static void main(String[] args) {
    System.out.println("block 1");
}

只能使用条件为常量的if语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句(eg:while)搭配,则可能在控制流分析中提示错误,被拒绝编译。

//不能使用其他条件语句来完成条件编译
public static void main(String[] args) {
    // 编译器将会提示“Unreachable code”
    while (false) {
        System.out.println("");
    }
}

这其实也是Java中的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成。 这种方式只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。

8.3.4 其他语法糖

除上述泛型、自动装箱、自动拆箱、循环遍历、变长参数和条件编译外,还有一些其他的语法糖,一一来简单看一下。

8.3.4.1 内部类

在使用非静态内部类时,内部类自动持有外部类的引用。而且编译器会为内部类生成一个新的class文件。

public class InnerClass {

    public void test() {
        Builder builder = new Builder();

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(builder);
            }
        };
        runnable.run();
    }

    public class Builder {
        private String name;
    }

}

上面是一段简单的代码,编译后会生成3个class文件InnerClass$1.classInnerClass$Builder.classInnerClass.class。它们编译之后的class反编译出来的代码分别如下:

//InnerClass$1.class
class InnerClass$1 implements Runnable {
    final InnerClass this$0;
    final InnerClass.Builder val$builder;

    InnerClass$1(InnerClass this$02, InnerClass.Builder builder) {
        this.this$0 = this$02;
        this.val$builder = builder;
    }

    public void run() {
        System.out.println(this.val$builder);
    }
}

//InnerClass$Builder.class
public class InnerClass$Builder {
    private String name;
    final InnerClass this$0;

    public InnerClass$Builder(InnerClass this$02) {
        this.this$0 = this$02;
    }
}

//InnerClass.class
public class InnerClass {
    public void test() {
        new 1(this, new Builder(this)).run();
    }
}

从上面我们可以得出一些结论:

  1. 外部类的引用通过构造方法传进去的,内部类一直持有着
  2. 非静态内部类里面使用了外部的数据,也是通过构造方法传进去的
  3. 非静态内部类会自动生成一个新的class文件
8.3.4.2 枚举类

其实enum就是一个普通的类,它继承自java.lang.Enum类。在JVM字节码文件结构中,并没有枚举这个类型,编译器会在编译期将其编译成一个普通的类。

public enum Fruit {
    APPLE,ORINGE
}

//编译之后
//继承java.lang.Enum并声明为final
public final class Fruit extends Enum
{

    public static Fruit[] values() {
        return (Fruit[])$VALUES.clone();
    }

    public static Fruit valueOf(String s) {
        return (Fruit)Enum.valueOf(Fruit, s);
    }

    private Fruit(String s, int i) {
        super(s, i);
    }
    //枚举类型常量
    public static final Fruit APPLE;
    public static final Fruit ORANGE;
    private static final Fruit $VALUES[];//使用数组进行维护

    static {
        APPLE = new Fruit("APPLE", 0);
        ORANGE = new Fruit("ORANGE", 1);
        $VALUES = (new Fruit[] {
            APPLE, ORANGE
        });
    }
}
8.3.4.3 数值字面量

Java支持的数值字面量:十进制、八进制(整数之前加0)、十六进制(整数之前加0x或0X)、二进制(整数之前加0b或0B)。在JDK7中,数值字面量的数字之间是允许插入任意多个下划线的,本身没有意义,只为方便阅读。

public class Test {
    public static void main(String[] args) {
        //十进制
        int a = 10;
        //二进制
        int b = 0B1010;
        //八进制
        int c = 012;
        //十六进制
        int d = 0XA;

        double e = 12_234_234.23;
        System.out.println("a:"+a);
        System.out.println("b:"+b);
        System.out.println("c:"+c);
        System.out.println("d:"+d);
        System.out.println("e:"+e);
    }
}

上面一段示例代码在编译之后是下面这样:

public class Test {

    public Test() {
    }

    public static void main(String args[]) {
        int a = 10;
        //编译器已经将二进制,八进制,十六进制数转换成了10进制数
        int b = 10;
        int c = 10;
        int d = 10;
        //编译器已经将下滑线删除
        double e = 12234234.23D;
                           //字符串+号替换成了StringBuilder的append
        System.out.println((new StringBuilder()).append("a\uFF1A").append(a).toString());
        System.out.println((new StringBuilder()).append("b\uFF1A").append(b).toString());
        System.out.println((new StringBuilder()).append("c\uFF1A").append(c).toString());
        System.out.println((new StringBuilder()).append("d\uFF1A").append(d).toString());
        System.out.println((new StringBuilder()).append("e\uFF1A").append(e).toString());
    }
}

在编译之后,全部都转换成了十进制,下划线也没了。同时,字符串+号替换成了StringBuilder的append。

8.3.4.4 对枚举和字符串的switch支持

switch对枚举和String的支持原理其实是差不多的。switch关键字原生只能支持整数类型,如果switch后面是String类型的话,编译器会将其转换成该字符串的hashCode的值,然后switch就通过这个hashCode的值进行case。

如果switch后面是Enum类型,则编译器会将其转换为枚举定义的下标,也还是整数类型。

String str = "world";
switch(str) {
    case "hello":
        System.out.println("hello");
        break;
    case "world":
        System.out.println("world");
        break;
    default:
        break;
}

编译之后的class再反编译之后的代码:

String str = "world";
String s;
switch((s = str).hashCode()) {
    default:
        break;
    case 99162322:
        //再次通过equals方法进行判断,因为不同字符串的hashCode值是可能相同的,比如“Aa”和“BB”的hashCode就是一样的
        if(s.equals("hello"))
            System.out.println("hello");
        break;
    case 113318802:
        if(s.equals("world"))
            System.out.println("world");
        break;
}
8.3.4.5 try语句中定义和关闭资源

当一个外部资源的句柄对象实现了AutoCloseable接口,JDK 7中便可以利用try-with-resource语法更优雅的关闭资源。

try (FileInputStream inputStream = new FileInputStream(new File("test"))) {
    System.out.println(inputStream.read());
} catch (IOException e) {
    throw new RuntimeException(e.getMessage(), e);
}

当这个try-catch代码块执行完毕后,Java会确保外部资源的close方法被调用。代码瞬间非常简洁,但是这只是语法糖,并不是JVM新增的功能。下面是编译之后的代码

try {
    FileInputStream inputStream = new FileInputStream(new File("test"));
    Throwable var2 = null;
    try {
        System.out.println(inputStream.read());
    } catch (Throwable var12) {
        var2 = var12;
        throw var12;
    } finally {
        if (inputStream != null) {
            if (var2 != null) {
                try {
                    inputStream.close();
                } catch (Throwable var11) {
                    var2.addSuppressed(var11);
                }
            } else {
                inputStream.close();
            }
        }
    }

} catch (IOException var14) {
    throw new RuntimeException(var14.getMessage(), var14);
}

编译器帮我们做了关闭资源的操作。

8.3.4.6 Lambda表达式

Lambda表达式用着很舒服,代码看着也简洁。它其实也是语法糖,由编译器推断并将其转换成常规的代码。

public class LambdaTest{

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        list.add("I");

        list.forEach( e -> System.out.println("输出:"+e));
    }
}

反编译后的代码:

public class LambdaTest {
    public static void main(String[] arrstring) {
        ArrayList arrayList = new ArrayList();
        arrayList.add("I");
        arrayList.forEach((Consumer<String>)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)());
    }

    private static /* synthetic */ void lambda$main$0(String string) {
        System.out.println("\u6748\u64b3\u56ad:" + string);
    }
}

上面的Lambda表达式最终是被换成了Consumer(一个接口),然后在forEach方法里面循环调用Consumer的accept()方法。

9. 后端编译与优化

9.1 概述

在前端编译器中,“优化”手段主要用于提升程序的编码效率,之所以把javac这类将Java代码变为字节码的编译器称作前端编译器,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于Java虚拟机内部的后端编译器来完成代码优化以及从字节码生成本地机器码的过程,即之前多次提到的即时编译器或提前编译器,这个后端编译器的编译速度及编译结果质量高低,是衡量Java虚拟机性能最重要的一个指标。

本节中提及的即时编译器都是特指HotSpot虚拟机内置的即时编译器,虚拟机也是特指HotSpot虚拟机

9.2 即时编译器

目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

9.2.1 解释器与编译器

主流的商用Java虚拟机内部都同时包含解释器与编译器,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。

HotSpot虚拟机内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别称为客户端编译器和服务端编译器,或者简称为C1编译器和C2编译器。第三个是在JDK10才出现的、长期目标是替代C2的Graal编译器,Graal编译器目前还处于实验状态。

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为混合模式。用户也可以控制让虚拟机运行于解释模式,这样编译器不介入工作,全部代码由解释方式执行。也可以强制虚拟机运行于编译模式,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

为了在程序启动速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次。

9.2.2 编译对象与触发条件

在运行过程中会被即时编译器编译的目标是热点代码,主要有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为热点探测,其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:

  1. 基于采样的热点探测:周期性地检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。
  2. 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法建立计数器,统计方法的执行此时,如果执行次数超过一定的阈值就认为它是热点方法。

在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器(回边的意思是指在循环边界往回跳转)。

当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。

执行引擎默认不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如下图所示:

方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减,而这段时间被称为此方法的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的。

回边计数器,它的作用是统计一个方法中循环体代码执行的次数。当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就将回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。

9.2.3 编译过程

默认情况下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。

在后台执行编译的过程中,对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部的优化,而放弃了许多耗时较长的全局优化手段

  1. 第一阶段,一个平台独立的前端将字节码构造成一种高级中间代码(HIR)表示
  2. 第二阶段,一个平台相关的后台从HIR中产生低级中间代码(LIR)表示。
  3. 第三阶段,在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。

而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器。它会执行大部分经典的优化动作,如无用代码消除、循环展开、常量传播、消除公共子表达式等。

9.3 提前编译器

2013年,在Android中使用提前编译的ART横空出世。

9.3.1 提前编译的优劣得失

现在提前编译产生和对其的研究有着两条明显的分支,一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进行使用)时直接把它加载进来使用。

先说第一条,这是传统的提前编译应用形式,它在Java中存在的加载直指即时编译的最大弱点:即时编译要占用程序运行时间与运算资源

如果是在程序运行之前进行的静态编译,耗时的优化就可以放心大胆地进行了。这也是ART打败Dalvik的主要武器之一,连副作用也是相似的。在Android 5.0和6.0版本,安装一个稍微大一点的Android应用就得很长时间,以至于从Android 7.0版本起重新启用了解释执行和即时编译(但这与Dalvik无关,它彻底凉了),等空闲时系统再在后台自动进行提前编译。

关于提前编译的第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能达到最高性能的问题。

尽管即时编译在时间和运算资源方面的劣势是无法忽视的,但其依然有自己的优势:

  1. 性能分析制导优化:如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。
  2. 激进预测性优化:如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,不会出现无法挽救的后果。
  3. 链接时优化:Java语言天生就是动态链接的,一个个class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码。

9.4 编译器优化技术

编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。

9.4.1 优化技术概览

即时编译器对这些代码优化变化是建立在代码的中间表示或者是机器码之上的,绝不是直接在Java源码上去做的,这里只是笔者为了方便讲解,使用了Java语言的语法来表示这些优化技术所发挥的作用。

先来个简单示例,这是优化前的原始代码:

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;
}

第二步进行冗余访问消除

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;
}

第四步进行无用代码消除,可能是永远不会执行的代码,也可能是完全没有意义的代码。

public void foo() {
    y = b.value;
    // ...do stuff...
    sum = y + y;
}

9.4.2 方法内联

方法内联是编译器最重要的优化手段。除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。 方法内联的优化行为不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上,虚拟机的实现内联过程非常复杂。

对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本。如有ParentB和SubB是两个具有继承关系的父子类型,并且子类重写了父类的get()方法,那么b.get()是执行父类的get()方法还是子类的get()方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。

为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。编译器进行内联时就会分不同情况采取不同的处理:如果是非虚方法,那么直接进行内联;如果是虚方法,则先向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查到只有一个版本,那就假设应用程序的全貌就是现在运行的这个样子来进行内联,这种内联被称为守护内联。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。

假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存的方式来缩短方法调用的开销。

所以,大多数情况下Java虚拟机进行的方法内联都是一种激进优化。

9.4.3 逃逸分析

逃逸分析是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,,而是为其他优化措施提供依据的分析技术。

逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程之外(别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:

  • 栈上分配:如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不支持线程逃逸。
  • 标量替换:若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。把一个Java对象拆散,根据程序访问额情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
  • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以完全地消除掉。

下面举个例子来分析一下逃逸分析是如何工作的,下面这些是伪代码:

// 完全未优化的代码
public int test(int x) {
    int xx = x + 2;
    Point p = new Point(xx, 42); 
    return p.getX();
}

第一步,将Point的构造函数和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()方法的范围内Point对象实例不会发生任何程序的逃逸,这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免Point对象实例被实际创建,优化后如下:


// 步骤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; 
}

尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。

9.4.4 公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可以称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。

例如:

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

其中c*bb*c是一样的表达式,而且再计算期间b与c的值是不变的,所以这条表达式可能被视为:

int d = E * 12 + a + (a + E);

这时编译器还可能进行另外一种优化——代数化简,在E本来就有乘法运算的前提下,把表达式变为:

int d = E * 13 + a + a;

9.4.5 数组边界检查消除

数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。如果有一个数组foo[],在Java语言中访问数组元素f00[i]的时候系统将会自动进行上下界的范围检查,即i必须满足i>=0 && i<foo.length的访问条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException

为了安全,数组边界检查肯定是要做的,但是数组边界检查是不是必须在运行期间一次不漏地进行则是可以商量的事情。常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可以把整个数组的上下界检查消除掉,可以节省很多次的条件判断操作。

Java要做很多检查判断,为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提前到编译器完成的思路之外,还有一种避开的处理思路——隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种方案。

//伪代码 虚拟机访问foo.value
if (foo != null) {
    return foo.value;
}else{
    throw new NullPointException();
}

//隐式异常优化之后
try {
    return foo.value;
} catch (segment_fault) {
    uncommon_trap();
}

虚拟机会注册一个Segment Fault信号的异常处理器,务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。当foo极少为空的时候,隐式异常优化是值得的,但加入foo经常为空,这样的优化反而会让程序更慢。幸好HotSpot虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。

10. Java内存模型与线程

10.1 硬件的效率与一致性

由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现在计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高级缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是它引入了一个新问题:缓存一致性。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统成为共享内存多核系统。为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。

除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序优化。

10.2 Java内存模型

Java内存模型(JMM):屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

10.2.1 主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

Java内存模型规定所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(可与前面讲的高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

需要注意的是,volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一样。

10.2.2 内存间交互操作

关于主内存与工作内存之间的具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如果从工作内存同步回主内存这一类的实现细节,Java内存模型定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)

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

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

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。

当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的可见性是指当一个线程修改了这个变量的值,新值对于其他线程来说可以立即得知。第二个语义是禁止指令重排序优化

volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的

下面是自增运算race++的javap反编译代码:


public static void increase(); 
    Code:
        Stack=2, Locals=0, Args_size=0
        0: getstatic
        3: iconst_1
        4: iadd
        5: putstatic
        8: return
    LineNumberTable: 
        line 14: 0 
        line 15: 8

当getstatic指令把race的值取到操作数栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存之中。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:

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

下面的代码的场景就很适合使用volatile变量来控制并发:

volatile boolean shutdownRequested;
public void shutdown() { 
    shutdownRequested = true;
}
public void doWork() {
    while (!shutdownRequested) {
        // 代码的业务逻辑 
    }
}

可见性原理: 当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,这个lock指令第一个作用是将这个缓存中的变量回写到系统主存中;第二个作用是这个写内存的操作会使其他CPU里缓存了该内存地址的数据无效.但是就算回写到内存,如果其他处理器缓存的值还是旧的,还是有问题,为了保证各个处理器的缓存是一致的,就会实现一致性协议,即每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,如果内存地址被修改就会把当前处理器的缓存行设置为无效状态,当处理器对这个数据进行操作的时候,就会重新拉一份新的值.

有序性原理: 有序性是通过内存屏障来实现的,具体实现:

  1. 在volatile写操作的的前面插入一个StoreStore屏障,保障volatile写操作不会和之前的写操作重排序
  2. 在volatile写操作的后面插入一个StoreLoad屏障,保障volatile写操作不会和之后的读操作重排序
  3. 在volatile读操作的后面插入一个LoadLoad屏障+LoadSore屏障,保证volatile读操作不会和之后的读操作,写操作重排序

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

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

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

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

10.2.5.1 原子性

大致可以认为,基本数据类型的访问、读写都是具备原子性的。

如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

10.2.5.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。可以说是volatile保证了多线程操作时的可见性,而普通变量则不能保证这一点。

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见性是由”对一个变量执行unlock操作之前,必须先把次变量同步回主内存中“这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完毕,并且构造器没有把this引用传递出去,那么在其他线程中就能看见final字段的值。

10.2.5.3 有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指线程内似表现为串行的语义,后半句是指指令重排序现象和工作内存与主内存同步延迟现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由一个变量在同一时刻只允许一条线程对其进行lock操作这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

10.2.6 先行发生原则

先行发生原则:它是判断数据是否存在竞争,线程是否安全的非常有用的手段。

先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些天然的先行发生关系,这些先行发生关系无需任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意进行重排序。

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切以先行发生原则为准。

10.3 Java与线程

10.3.1 线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。目前线程是Java里面进行处理器资源调度的最基本单位。

实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)

10.3.1.1 内核线程实现

使用内核线程实现的方式也被称为1:1实现。内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

10.3.1.2 用户线程实现

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

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。

10.3.1.3 混合实现

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

10.3.1.4 Java线程的实现

以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的,全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时机、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。

10.3.2 状态转换

Java线程有6种状态,任一时间点,一个线程只能有一种状态。

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

11. 线程安全与锁优化

11.1 线程安全

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

11.1.1 线程安全的实现方法

11.1.1.1 互斥同步

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

最基本的互斥同步手段就是synchronized关键字,这是一种块结构的同步语法。synchronized关键字经过javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的class对象来作为线程要持有的锁。

在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

  • 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况
  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

从执行成本的角度看,持有锁是一个重量级的操作。

自JDK5起,Java类库中新提供了java.util.concurrent包,其中java.util.concurrent.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构来实现互斥同步。

重入锁(ReentrantLock)是Lock接口最常见的一种实现,它与synchronized一样是可重入的。ReentrantLock与synchronized相比增加了一些高级功能,主要有:等待可中断、可实现公平锁、可以绑定多个条件。

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助
  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁:而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock性能急剧下降,会明显影响吞吐量。
  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多余一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。

JDK6中加入了大量针对synchronized锁的优化措施,所以性能不再是选择synchronized或者ReentrantLock的决定因素。

ReentrantLock在功能上是synchronized的超集,在性能上又至少不会弱于synchronized,那么为什么不直接抛弃synchronized? 下面这些场景下,synchronized比ReentrantLock更适合

  • synchronized是在Java语法层面的同步,简单&清晰。每个Java程序员都熟悉synchronized,但JUC中的Lock接口则并非如此。因此在只需要基础的同步功能时,更推荐synchronized
  • Lock应该确保在finally中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized的话,则可以由Java虚拟机来确保即时出现异常,锁也能被自动释放
  • 尽管在JDK5时代ReentrantLock曾经在性能上领先过synchronized,但这已经是十多年前的胜利了。从长远来看,Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息,而使用JUC中的Lock的话,Java虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。
11.1.1.2 非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步。从解决问题的方式看,互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。随着硬件指令集的发展,现在有了另一个选择:基于冲突检测的乐观并发策略,先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施就是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步,使用这种措施的代码也被称为无锁编程。

硬件保证了某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-swap,CAS)
  • 加载链接/条件存储(Load-Linked/St ore-Condit ional)

CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。

在JDK5之后,Java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。HotSpot虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了。

CAS存在的问题也比较多,但是现在基本都有解决方案。

  • ABS问题(中途有其他线程修改了A的值,但是在结束之前又改回去了,中间一小部分时间的数据是不对的,根据业务场景判断这种释放需要处理),Java的解决思路就是加一个版本号,可以使用AtomicStampedReference来解决
  • 循环时间长开销大,这个问题的解决可以参考Java8新增的LongAdder类。在高并发场景下,大量线程会同时去竞争更新同一个原子变量,但是由于同时只有一个线程CAS操作会成功,这就造成了大量线程竞争失败后自旋继续尝试,验证损耗CPU,这时候LongAdder的思路就是将一个变量分为多个变量,让多个线程去竞争多个资源,也就是把long值分为一个base加上一个Cell数组,最后取值时就是base加上多个Cell的值
  • CAS操作是针对一个变量的,如果对多个变量操作,可以:1. 加锁来解决 2. 封装成对象类解决
11.1.1.3 无同步方案

要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步指数保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。

可重入代码:这种代码又称纯代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。 如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

11.2 锁优化

JDK6上有大量锁优化技术:适应性自旋、锁消除、锁膨胀、轻量级锁、偏向锁等。这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

11.2.1 自旋锁与自适应自旋

如果物理机器上有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这就是自旋锁。

自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的世界很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是10次。

在JDK6中对自旋锁的优化,引入了自适应的自旋锁。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越聪明。

11.2.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

11.2.3 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快的拿到锁。

大多数情况下,上面的原则都是正确的,如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。


public String concatString(String s1, String s2, String s3) { 
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

上面代码中的连续append()就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。上面代码可能最后是扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。

11.2.4 轻量级锁

轻量级锁是JDK6时加入的新型锁机制,它名字中的轻量级是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为重量级锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据被称为Mark Word,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。

JVM开发者发现在很多情况下,synchronized中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用CAS就可以解决(当有一个线程竞争获取锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID,就会进行CAS操作获取该锁.),这种情况下,用完全互斥的重量级锁是没必要的.轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞.

11.2.5 偏向锁

如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想.一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好.

资料