JVM-类加载


六、类加载

0、使用类的步骤流程

img

1、类加载阶段

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

这里写图片描述

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持java语言的动态绑定。

在了解各个阶段的大概作用之前,还需要解决的一个问题就是如何虚拟机在何时会对类进行加载。实际上虚拟机规范没有对类加载的时机进行规定,而规定了类初始化的时机,并且规定有且只有下面5种情况需要立即对类进行初始化(在初始化之前自然需要进行加载、验证、准备几个阶段)。

1) 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类没有进行初始化,则需要先触发其初始化。生成这四条指令的场景Java代码场景是:使用new关键词实例化类的对象、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法的时候。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类还没有进行过初始化,则需要先进行初始化。

3)当初始化一个类的时候,如果发现其父类还没有初始化,则需要先初始化其父类。

4)当虚拟机启动时,用户需要制定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类

  1. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

上面这五种方式被称为一个类的主动引用,除此之外所有引用类的方法都不会触发初始化,称为被动引用(比较常见的就是对类常量的引用,另外通过子类来引用父类的静态变量也属于被动引用,不会触发子类的初始化)。对于接口和类的初始化规则还有一些不同,主要是对类进行初始化的时候要求其父类的初始化已经全部完成了,但对接口进行初始化的时候并不要求其父接口都完成了初始化,只要在真正使用到父接口(如引用父接口中静态变量)才会初始化。下面看下加载、验证、准备、解析、初始化这5个阶段各自的工作。

(1)加载

  • 将类的字节码载入方法区(1.8后为元空间,在本地内存中)中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:

    • _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
  • _super 即父类

    • _fields 即成员变量
  • _methods 即方法

    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法
  • 如果这个类还有父类没有加载,先加载父类

  • 加载和链接可能是交替运行

img

  • instanceKlass保存在方法区。JDK 8以后,方法区位于元空间中,而元空间又位于本地内存中
  • _java_mirror则是保存在堆内存
  • InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址
  • 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass,从而获取类的各种信息

注意

  • instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内),但 _java_mirror 是存储在堆中
  • 可以通过前面介绍的 HSDB 工具查看

加载阶段主要完成下面三件事情:

(1)通过一个类的全限定名来获取定义此类的二进制字节流。

(2)将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构(这里可以理解为字节流的格式是虚拟机规范规定的,而每个虚拟机中对Java类的数据结构有自己的规定,这一步将外部的二进制字节流转换为虚拟机需要的格式存储在方法区之中)。

(3)在内存中共生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口。

上面三个阶段中的第一个步骤”通过一个类的全限定名来获取定义该类的二进制字节流”是开发人员可控性最强的,因为虚拟机规范并没有规定一定要从Class文件中获取,所以可以通过定义自己的类加载器来完成(通过重写一个类加载器的loadClass()方法),可以实现从jar、zip、war等压缩包中读取,也可以同网络中获取,甚至可以在运行是计算生成(例如java的动态代理就是利用ProxyGenerator.generateProxyClass()来为特定接口生成代理类的二进制字节流)。

(2)连接-验证

验证类是否符合 JVM规范,安全性检查

(以下部分看一下即可)

验证是连接阶段的第一步,这一步的目的是为了确保Class文件字节流中包含的信息符合当前虚拟机的要求,并且不会威胁虚拟机自身的安全。其实如果纯粹是从Java源码编译得到的Class文件,自身是可以确保安全的,但是因为Class文件可以由任何途径产生(甚至可以由十六进制编辑器直接编写来产生Class文件),所以虚拟机很有必要对输入的字节流进行验证以维护自身的安全。

验证阶段需要完成四个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证主要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,这个阶段是基于二进制字节流进行的,验证的目的是为了保证输入的字节流符合Class文件规范能够正确的解析并存储于方法区内,通过这个阶段的校验之后字节流才能进入到内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

元数据验证和字节码验证主要是对字节码的语义分析和对数据流和控制流进行分析,做一些确保代码逻辑的验证工作;

最后的符号引用验证发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在链接的第三个阶段–解析阶段。符号引用验证可以看成是对类自身以外的信息进行匹配性校验的过程,简单的来说就是看下符号引用通过字符串形式描述的类是否存在并且类的定义是否规范。

(3)连接-准备

准备阶段是正式为类实例变量分配内存并且设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里的类变量指的是被static修饰的变量,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在java堆中;另外这里的初始值不是代码中指的初始值而是变量数据类型对应的零值(int为0,String为null等)。

为 static 变量分配空间,设置默认值

  • static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成将常量池中的符号引用解析为直接引用
// 一般情况:static final 修饰的基本数据类型、字符串类型字面量会在准备阶段赋值
private static final String str = "Hello world";
// 特殊情况:static final 修饰的引用类型不会在准备阶段赋值,而是在初始化阶段赋值
private static final String str = new String("Hello world");

准备阶段的显式赋值

满足的条件:

  • 静态常量 static final
  • 用 字面量 进行显示赋值,字面量(保存在常量池中) 可以认为是有确定值的基本数据类型,还有具有确定值的string.

初始化阶段的显式赋值

不满足上述条件的 静态变量或者静态常量(非字面量) 显式赋值,都要在初始化阶段的clinit()方法中进行显式赋值

当然了,如果没有静态变量显式赋值,或者静态常量显式赋值(非字面量) ,则编译器不会生成clinit()方法。

public class Code_22_AnalysisTest {

    public static void main(String[] args) throws ClassNotFoundException, IOException{
        ClassLoader classLoader = Code_22_AnalysisTest.class.getClassLoader();
        // 加载类,但不会触发类的解析和初始化
        Class<?> c = classLoader.loadClass("cn.ali.jvm.test.C");

        // new C();
        System.in.read();
    }

}

class C {
    D d = new D();
}

class D {

}

(4)连接-解析

解析阶段简单的来说就是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 未解析时,常量池中的看到的对象仅是符号,未真正的存在于内存中

  • 符号引用,仅仅只是个符号,并不知道类、类的方法、类的属性在内存中的位置

(5)初始化

初始化阶段就是执行类构造器clinit()方法的过程,虚拟机会保证这个类的『构造方法』的线程安全

  • clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的

注意

编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如

public class Test1 {

    static int x = 0;

    static{
        x = 1;    // 赋值成功
        System.out.println("static 代码块中的 x = " + x);  // 编译通过

        i = 1;  //对变量赋值可正常编译通过,但无法赋值
        // System.out.println(i);  //编译不通过,提示非法向前引用
    }

    static int i = 0;

    public static void main(String[] args) {
        System.out.println("x = " + x);
        System.out.println("i = " + i);
    }
}

输出

static 代码块中的 x = 1
x = 1
i = 0

发生时机

类的初始化的懒惰的,以下情况会初始化

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

以下情况不会初始化

  • 访问类的 static final 静态常量(基本类型和字符串)
  • 类对象.class 不会触发初始化
  • 创建该类对象的数组
  • 类加载器的.loadClass方法
  • Class.forNamed的参数2为false时

验证类是否被初始化,可以看该类的静态代码块是否被执行

package classload;

public class Test2 {
    static {
        System.out.println("main init");
    }
    public static void main(String[] args) throws ClassNotFoundException {
        // 1. 静态常量(基本类型和字符串)不会触发初始化
        System.out.println("访问静态常量" + B.b);
        // 2. 类对象.class 不会触发初始化
        System.out.println("class对象" + B.class);
        // 3. 创建该类的数组不会触发初始化
        System.out.println("创建该类的数组" + new B[0]);
        // 4. 不会初始化类 B,但会加载 B、A
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        cl.loadClass("cn.ali.jvm.test.classload.B");
        // 5. 不会初始化类 B,但会加载 B、A
        ClassLoader c2 = Thread.currentThread().getContextClassLoader();
        Class.forName("cn.ali.jvm.test.classload.B", false, c2);


        // 1. 首次访问这个类的静态变量或静态方法时
        System.out.println(A.a);
        // 2. 子类初始化,如果父类还没初始化,会引发
        System.out.println(B.c);
        // 3. 子类访问父类静态变量,只触发父类初始化
        System.out.println(B.a);
        // 4. 会初始化类 B,并先初始化类 A
        Class.forName("cn.ali.jvm.test.classload.B");
    }

}


class A {
    static int a = 0;
    static {
        System.out.println("a init");
    }
}
class B extends A {
    final static double b = 5.0;
    static boolean c = false;
    static {
        System.out.println("b init");
    }
}
<clinit>()方法

初始化是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序通过自定义类加载器参与之外,其余动作完全由虚拟机主导。到了初始化过程,才会真正开始执行类中定义的Java程序代码。这一步主要就是执行静态变量的初始化,包括静态变量的赋值和静态初始化块的执行。这里需要引入一个类构造器<clinit>()方法,下面对其进行简单的介绍:

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态初始化块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态初始化块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量静态语句块中可以赋值但是不能访问(这里有一点需要注意,静态初始化块和静态变量赋值语句执行顺序是按定义顺序来的,并不是说初始化块一定会在静态赋值语句之后执行)。

  • <clinit>()方法与类的构造函数不同,它不需要显示的调用父类的<clinit>()方法,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。

  • <clinit>()方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

  • 接口中不能使用静态初始化块,但是仍有static变量的赋值操作,所以也会有<clinit>()方法,但是接口执行<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量被使用到时,才会执行<clinit>()方法。

  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其它线程都需要阻塞等待。

(6)符号引用

符号引用
  • 直接引用,比如就是你拥有你所需要数据的地址值,可以直接根据地址值获取到数据。但是 java语言是解释性的语言,然后由于各种原因,在某些时刻有些东西的直接地址还并不存在,是无法使用直接引用。这时候就可以用到符号引用了。
  • 符号引用:符号引用是一个字符串,它给出了被引用的内容的名字并且可能会包含一些其他关于这个被引用项的信息——这些信息必须足以唯一的识别一个类、字段、方法。这样,对于其他类的符号引用必须给出类的全名。对于其他类的字段,必须给出类名、字段名以及字段描述符。对于其他类的方法的引用必须给出类名、方法名以及方法的描述符。这样我们就能根据符号引用锁定唯一的类,方法或字段了。
类描述符

例:

类:Java.lang.String
对应描述符:Ljava/lang/String;
  • 类描述符的规则是以L开头,其后跟着该类的全限定名,并将其中中的 “.” 改为 “/” ,最后分号“;”结束(只有 类描述符需要以分号结束好像,基本类型的不需要)。

再补上其他基本类型的符号(由于基本类型不存在全限定名,只需要一个符号就可以表达该基本类型)

java类型 符号
boolean Z
byte B
char C
short S
int I
long J
float F
double D
void V
字段描述符

我们确定一个字段,只需要字段描述符和字段名两部分。那好像除了字段名,字段描述符只需要描述类型就可以了,所以类描述符好像等价于了字段描述符。

例:

int num=3;
字段描述符:I;
字段名:num

但是字段还有数组类型,数组的描述规则如下

String[][] num=null;
字段描述符:[[Ljava/lang/String;
字段名:num

数组类型只需要原描述符在前面加一个[,如果是二维数组就加[[,以此类推。

方法描述符

例1:

String test();
方法描述符:()Ljava/lang/String;
方法名:test

例2:

long test(int i, Object c);
方法描述符:(ILjava/lang/Object;)J
方法名:test

例3:

void test(byte[] bytes);
方法描述符:([B)V
方法名:test

三个例子很清晰的就能看明白了,方法描述符就是“()”和参数与返回值的字段描述符的组合,组合规则是:
(第一个参数的字段描述符第二个参数的字段描述符…..)返回类型的描述符

(6)相关面试题

从字节码分析,使用 a,b,c 这三个常量是否会导致 E 初始化

public class Load2 {

    public static void main(String[] args) {
        System.out.println(E.a);    // 不会,在准备阶段进行赋值
        System.out.println(E.b);    // 不会,在准备阶段进行赋值
        // 会导致 E 类初始化,因为 Integer 是包装类,底层调用Integer.valueOf()
        System.out.println(E.c);
    }
}

class E {
    public static final int a = 10;
    public static final String b = "hello";
    public static final Integer c = 20;        //底层调用Integer.valueOf()

    static {
        System.out.println("E cinit");
    }
}

典型应用 - 完成懒惰初始化单例模式

public class Singleton {

    private Singleton() { } 
    // 内部类中保存单例
    private static class LazyHolder { 
        static final Singleton INSTANCE = new Singleton(); 
    }
    // 第一次调用 getInstance 方法,才会导致内部类加载和初始化其静态成员 
    public static Singleton getInstance() { 
        return LazyHolder.INSTANCE; 
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 初始化时的线程安全是有保障的

2、类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(ClassLoader)

(1)类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

以JDK 8为例

image-20230223220006758

名称 加载的类 说明
Bootstrap ClassLoader(启动类加载器) JAVA_HOME/jre/lib 无法直接访问
Extension ClassLoader(拓展类加载器) JAVA_HOME/jre/lib/ext 上级为Bootstrap,显示为null
Application ClassLoader(应用程序类加载器) classpath 上级为Extension
自定义类加载器 自定义 上级为Application

类加载器是有层级关系的,如上表格,当类加载器要加载一个类时,需要先询问上级类加载器是否已经加载了该类,如果已加载则无需再次加载。其实这就是双亲委派机制。

public class Load5_1 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.F");
        // getClassLoader 方法用于获取类加载器,如果返回值为null,则表示该类的类加载器为启动类加载器,因为启动类加载器是 c或者c++写的,java无法访问
        System.out.println(aClass.getClassLoader()); // AppClassLoader  ExtClassLoader
    }
}

获取类加载器

public static void main(String[] args) {
    //获取系统类加载器
    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
    System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

    //获取上层 扩展类加载器
    ClassLoader extClassLoader = systemClassLoader.getParent();
    System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1b6d3586

    //获取上层 引用类加载器  获取不到引用类加载器
    ClassLoader bootStrapClassLoader = extClassLoader.getParent();
    System.out.println(bootStrapClassLoader);//null

    //对于用户自定义类使用的加载器  默认使用系统类加载器加载
    ClassLoader classLoader = ClassLoader1.class.getClassLoader();
    System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

    //String类使用引导类加载器加载,Java核心类库都是使用引导类加载器加载
    ClassLoader StringClassLoader = String.class.getClassLoader();
    System.out.println(StringClassLoader);//null
}

(2)启动类加载器

也叫引导类加载器

这个类加载使用C/C++语言实现的,嵌套在JVM内部。

它用来加载Java的核心库(JAVA HOME/jre/lib/rt.jar.resources.jar或sun.boot.class. path路径下的内容) ,用于提供JVM自身需要的类

并不继承自 java.lang.ClassLoader,没有父加载器。

加载扩展类和应用程序类加载器,并指定为他们的父类加载器。

出于安全考虑, Bootstrap启动类加载器只加载包名为 java, javax.sun 等开头的类

System.out.println("------启动类加载器------");
//获取BootStrapClassLoader能够加载的API的路径
URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
for (URL url : urLs) {
    System.out.println(url.toExternalForm());
}

/*
------启动类加载器------
file:/E:/software/java/jdk/jre/lib/resources.jar
file:/E:/software/java/jdk/jre/lib/rt.jar
file:/E:/software/java/jdk/jre/lib/sunrsasign.jar
file:/E:/software/java/jdk/jre/lib/jsse.jar
file:/E:/software/java/jdk/jre/lib/jce.jar
file:/E:/software/java/jdk/jre/lib/charsets.jar
file:/E:/software/java/jdk/jre/lib/jfr.jar
file:/E:/software/java/jdk/jre/classes
*/

//从上面的路径中随意选择一个类,查看使用的类加载器
ClassLoader classLoader = Provider.class.getClassLoader();
System.out.println(classLoader);//null    //说明使用的是BootStrapClassLoader加载

Java 类由 java.lang.ClassLoader 的对象/实例加载。但是类加载器本身就是一个类。所以现在的问题是,谁加载 java.lang.ClassLoader 这个类呢?

答案是:启动类加载器。

它主要负责加载 JDK 内部类,通常是 rt.jar 和其他位于 $JAVA_HOME/jre/lib 目录下的核心类库。此外,启动类加载器是所有其他 ClassLoader 实例的parent。

(3)拓展类加载器

Java语言编写

扩展类加载器加载核心 Java 类的扩展类(ext/*.jar包下的类)。

如果 classpath 和 JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了,则不会再次加载

System.out.println("------扩展类加载器------");
//获取SystemClassLoader能够加载的API的路径
String extDirs = System.getProperty("java.ext.dirs");
for (String path : extDirs.split(";")) {
    System.out.println(path);
}

/*
------扩展类加载器------
E:\software\java\jdk\jre\lib\ext
C:\WINDOWS\Sun\Java\lib\ext
*/

//从上面的路径中随意选择一个类,查看使用的类加载器
ClassLoader classLoader = ECKeyFactory.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$ExtClassLoader@1b6d3586 //扩展类加载器

(4)应用类加载器

应用类加载器加载在classpath中的我们自己写的文件。

父类加载器为扩展类加载器

它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库

该类加载是程序中默认的类加载器,一般来说, Java应用的类都是由它来完成加载

通过 ClassLoader#getSystemClassLoader() 方法可以获取到该类加载器

(5)双亲委派模式

当类加载器要加载一个类时,需要先询问上级类加载器是否已经加载了该类,如果已加载则无需再次加载。其实这就是双亲委派机制。

双亲委派模式,即调用类加载器ClassLoader 的 loadClass 方法时,查找类的规则

loadClass源码

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException{

    synchronized (getClassLoadingLock(name)) {
        // 首先查找该类是否已经被该类加载器加载过了
        Class<?> c = findLoadedClass(name);
        //如果没有被加载过
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                //看是否被它的上级加载器加载过了 Extension的上级是Bootstarp,但它显示为null
                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) {
                //如果还是没有找到,先让拓展类加载器调用findClass方法去找到该类,如果还是没找到,就抛出异常
                //然后让应用类加载器去找classpath下找该类
                long t1 = System.nanoTime();
                c = findClass(name);

                // 记录时间
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

(6)线程上下文类加载器

我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写

Class.forName("com.mysql.jdbc.Driver")

也是可以让 com.mysql.jdbc.Driver 正确加载的,你知道是怎么做的吗?

让我们追踪一下源码:

public class DriverManager { 
    // 注册驱动的集合 
    private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>(); 
    // 初始化驱动 
    static { 
        loadInitialDrivers(); 
        println("JDBC DriverManager initialized"); 
    }

先不看别的,看看 DriverManager 的类加载器:

System.out.println(DriverManager.class.getClassLoader());

打印 null,表示它的类加载器是 Bootstrap ClassLoader,会到 JAVA_HOME/jre/lib 下搜索类,但 JAVA_HOME/jre/lib 下显然没有 mysql-connector-java-5.1.47.jar 包,这样问题来了,在DriverManager 的静态代码块中,怎么能正确加载 com.mysql.jdbc.Driver 呢?

继续看 loadInitialDrivers() 方法:

private static void loadInitialDrivers() { 
    String drivers; 
    try {
        drivers = AccessController.doPrivileged(new PrivilegedAction<String> () { 
            public String run() { 
                return System.getProperty("jdbc.drivers"); 
            } 
        }); 
    } catch (Exception ex) { 
        drivers = null; 
    }
    // 1)使用 ServiceLoader 机制加载驱动,即 SPI 
    AccessController.doPrivileged(new PrivilegedAction<Void>() { 
        public Void run() { 
            ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
            Iterator<Driver> driversIterator = loadedDrivers.iterator();
            try{
                while(driversIterator.hasNext()) { 
                    driversIterator.next(); 
                } 
            } catch(Throwable t) { 
                // Do nothing 
            }
            return null; 
        } 
    }); 
    println("DriverManager.initialize: jdbc.drivers = " + drivers); 
    // 2)使用 jdbc.drivers 定义的驱动名加载驱动 
    if (drivers == null || drivers.equals("")) { 
        return; 
    }
    String[] driversList = drivers.split(":"); 
    println("number of Drivers:" + driversList.length); 
    for (String aDriver : driversList) { 
        try {
            println("DriverManager.Initialize: loading " + aDriver); 
            // 这里的 ClassLoader.getSystemClassLoader() 就是应用程序类加载器 
            Class.forName(aDriver, true, ClassLoader.getSystemClassLoader()); 
        } catch (Exception ex) { 
            println("DriverManager.Initialize: load failed: " + ex); 
        } 
    } 
}

先看 2)发现它最后是使用 Class.forName 完成类的加载和初始化,关联的是应用程序类加载器,因此可以顺利完成类加载

再看 1)它就是大名鼎鼎的 Service Provider Interface (SPI)

约定如下,在 jar 包的 META-INF/services 包下,以接口全限定名名为文件,文件内容是实现类名称

image-20211104214150216

这样就可以使用

ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class);
Iterator<接口类型> iter = allImpls.iterator(); 
while(iter.hasNext()) { 
    iter.next(); 
}

来得到实现类,体现的是【面向接口编程+解耦】的思想,在下面一些框架中都运用了此思想:

  • JDBC

  • Servlet 初始化器

  • Spring 容器

  • Dubbo(对 SPI 进行了扩展)

接着看 ServiceLoader.load 方法:

public static <S> ServiceLoader<S> load(Class<S> service) { 
    // 获取线程上下文类加载器 ,拿到的也是应用程序类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader(); 
    return ServiceLoader.load(service, cl); 
}

线程上下文类加载器是当前线程使用的类加载器,默认就是应用程序类加载器,它内部又是由Class.forName 调用了线程上下文类加载器完成类加载,具体代码在 ServiceLoader 的内部类LazyIterator 中:

private S nextService() { 
    if (!hasNextService()) 
        throw new NoSuchElementException(); 
    String cn = nextName; nextName = null; 
    Class<?> c = null; 
    try {
        c = Class.forName(cn, false, loader); 
    } catch (ClassNotFoundException x) { 
        fail(service,"Provider " + cn + " not found"); 
    }if (!service.isAssignableFrom(c)) { 
        fail(service, "Provider " + cn + " not a subtype"); 
    }try {
        S p = service.cast(c.newInstance()); 
        providers.put(cn, p); 
        return p; 
    } catch (Throwable x) { 
        fail(service, "Provider " + cn + " could not be instantiated", x); 
    }
    throw new Error(); // This cannot happen 
}

(7)自定义类加载器

  • 准备好两个类文件放入 E:\myclasspath,它实现了 java.util.Map 接口

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

/**
* 自定义类加载器
*/
public class Load7 {
    public static void main(String[] args) throws Exception {
        MyClassLoader classLoader = new MyClassLoader();
        Class<?> c1 = classLoader.loadClass("MapImpl1");
        Class<?> c2 = classLoader.loadClass("MapImpl1");
        // 相同的类加载器,true
        System.out.println(c1 == c2);

        MyClassLoader classLoader2 = new MyClassLoader();
        Class<?> c3 = classLoader2.loadClass("MapImpl1");
        // 不同的类加载器,false
        System.out.println(c1 == c3);

        // 通过反射创建对象
        c1.newInstance();
    }
}

class MyClassLoader extends ClassLoader {

    @Override // name 就是类名称
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = "e:\\myclasspath\\" + name + ".class";

        try {
            ByteArrayOutputStream os = new ByteArrayOutputStream();
            Files.copy(Paths.get(path), os);

            // 得到字节数组
            byte[] bytes = os.toByteArray();

            // 字节数组转化为类 byte[] -> *.class 
            return defineClass(name, bytes, 0, bytes.length);

        } catch (IOException e) {
            e.printStackTrace();
            throw new ClassNotFoundException("类文件未找到", e);
        }
    }
}

(8)破坏双亲委派模式

  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK1.2面世以前的“远古”时代
    • 建议用户重写findClass()方法,在类加载器中的loadClass()方法中也会调用该方法
  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的
    • 如果有基础类型又要调用回用户的代码,此时也会破坏双亲委派模式
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的
    • 这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等

3、运行期优化

(1)即时编译

1.1 分层编译

例子:

// 当循环了一定次数后,单次循环的时间陡然下降
public class JIT1 { 
    public static void main(String[] args) { 
        for (int i = 0; i < 200; i++) { 
            long start = System.nanoTime(); 
            for (int j = 0; j < 1000; j++) { 
                new Object(); 
            }
            long end = System.nanoTime(); 
            System.out.printf("%d\t%d\n",i,(end - start)); 
        } 
    } 
}

JVM 将执行状态分成了 5 个层次:

  • 0层:解释执行,用解释器将字节码翻译为机器码
  • 1层:使用 C1 即时编译器编译执行(不带 profiling)
  • 2层:使用 C1 即时编译器编译执行(带基本的profiling)
  • 3层:使用 C1 即时编译器编译执行(带完全的profiling)
  • 4层:使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的 回边次数】等

即时编译器(JIT)与解释器的区别

  • 解释器

将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
是将字节码解释为针对所有平台都通用的机器码

  • 即时编译器

将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
根据平台类型,生成平台特定的机器码

对于大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由 来),并优化这些热点代码。

刚才的一种优化手段称之为【逃逸分析】,发现新建的对象是否逃逸。可以使用 -XX:-DoEscapeAnalysis 关闭逃逸分析,再运行刚才的示例观察结果

参考资料:https://docs.oracle.com/en/java/javase/12/vm/java-hotspot-virtual-machine-performance-enhancements.html#GUID-D2E3DC58-D18B-4A6C-8167-4A1DFB4888E4

1.2 逃逸分析

逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术

逃逸分析的 JVM 参数如下:

开启逃逸分析:-XX:+DoEscapeAnalysis
关闭逃逸分析:-XX:-DoEscapeAnalysis
显示分析结果:-XX:+PrintEscapeAnalysis

逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态,可以不用额外加这个参数

对象逃逸状态

1、全局逃逸(GlobalEscape)

即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:

  • 对象是一个静态变量
  • 对象是一个已经发生逃逸的对象
  • 对象作为当前方法的返回值

2、参数逃逸(ArgEscape)

即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的

3、没有逃逸

即方法中的对象没有发生逃逸

逃逸分析优化

针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化

锁消除

我们知道线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁

例如,StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作

锁消除的 JVM 参数如下:

开启锁消除:-XX:+EliminateLocks
关闭锁消除:-XX:-EliminateLocks

锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上

标量替换

首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象

对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。

这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能

标量替换的 JVM 参数如下:

开启标量替换:-XX:+EliminateAllocations
关闭标量替换:-XX:-EliminateAllocations
显示标量替换详情:-XX:+PrintEliminateAllocations

标量替换同样在 JDK8 中都是默认开启的,并且都要建立在逃逸分析的基础上

栈上分配

当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能

1.3 方法内联

内联函数

内联函数就是在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换

JVM内联函数

C++ 是否为内联函数由自己决定,Java 由编译器决定。Java 不支持直接声明为内联函数的,如果想让他内联,你只能够向编译器提出请求: 关键字 final 修饰 用来指明那个函数是希望被 JVM 内联的,如

public final void doSomething() {  
        // to do something  
}

总的来说,一般的函数都不会被当做内联函数,只有声明了final后,编译器才会考虑是不是要把你的函数变成内联函数

JVM内建有许多运行时优化。首先短方法更利于JVM推断。流程更明显,作用域更短,副作用也更明显。如果是长方法JVM可能直接就跪了。

第二个原因则更重要:方法内联

如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身,如:

private int add4(int x1, int x2, int x3, int x4) { 
    //这里调用了add2方法
    return add2(x1, x2) + add2(x3, x4);  
}  

private int add2(int x1, int x2) {  
    return x1 + x2;  
}

方法调用被替换后

private int add4(int x1, int x2, int x3, int x4) {  
    //被替换为了方法本身
    return x1 + x2 + x3 + x4;  
}

案例

package cn.itcast.jvm.t3.jit;

import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;

public class JIT2 {
    // -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:CompileCommand=dontinline,*JIT2.square
    // -XX:+PrintCompilation

    public static void main(String[] args) {

        int x = 0;
        for (int i = 0; i < 500; i++) {
            long start = System.nanoTime();
            for (int j = 0; j < 1000; j++) {
                x = square(9);

            }
            long end = System.nanoTime();
            System.out.printf("%d\t%d\t%d\n",i,x,(end - start));
        }
    }

    private static int square(final int i) {
        return i * i;
    }
}

输出

...
74    81    28200
75    81    28100
76    81    28200
77    81    13700
78    81    2100
79    81    2000
80    81    4900
81    81    2100
82    81    2000
...
227    81    2100
228    81    2100
229    81    57300
230    81    16000
231    81    100
232    81    0
233    81    100
234    81    100
235    81    0
236    81    0
237    81    0
...

如果发现 square 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置:

System.out.println(9 * 9);

还能够进行常量折叠(constant folding)的优化

System.out.println(81);

1.4 字段优化

// 这些注解见 六、4 JMH 性能测试工具
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Benchmark)
public class Benchmark1 {
    int[] elements = randomInts(1_000);

    private static int[] randomInts(int size) {
        Random random = ThreadLocalRandom.current();
        int[] values = new int[size];
        for (int i = 0; i < size; i++) {
            values[i] = random.nextInt();
        }
        return values;
    }

    @Benchmark
    public void test1() {
        for (int i = 0; i < elements.length; i++) {
            doSum(elements[i]);
        }
    }

    @Benchmark
    public void test2() {
        int[] local = this.elements;
        for (int i = 0; i < local.length; i++) {
            doSum(local[i]);
        }
    }

    @Benchmark
    public void test3() {
        for (int element : elements) {
            doSum(element);
        }
    }

    static int sum = 0;

    @CompilerControl(CompilerControl.Mode.INLINE)
    static void doSum(int x) {
        sum += x;
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder().include(Benchmark1.class.getSimpleName()).forks(1).build();
        new Runner(opt).run();
    }
}

首先启用 doSum 的方法内联,测试结果如下(每秒吞吐量,分数越高的更好):

Benchmark          Mode  Cnt        Score        Error  Units
Benchmark1.test1  thrpt    5  3752627.290 ± 151455.902  ops/s
Benchmark1.test2  thrpt    5  3766470.619 ±  63444.762  ops/s
Benchmark1.test3  thrpt    5  3745846.931 ± 120387.140  ops/s

分数(score)越高,就代表这个方法的执行效率越高

接下来禁用 doSum 方法内联

@CompilerControl(CompilerControl.Mode.DONT_INLINE) 
static void doSum(int x) { 
    sum += x; 
}

测试结果如下

Benchmark          Mode  Cnt       Score       Error  Units
Benchmark1.test1  thrpt    5  224549.715 ±  7327.427  ops/s
Benchmark1.test2  thrpt    5  254627.495 ± 12113.317  ops/s
Benchmark1.test3  thrpt    5  254994.178 ±  6051.895  ops/s

分析:

在刚才的示例中,doSum 方法是否内联会影响 elements 成员变量读取的优化:

如果 doSum 方法内联了,刚才的 test1 方法会被优化成下面的样子(伪代码):

@Benchmark 
public void test1() { 
    // elements.length 首次读取会缓存起来 -> int[] local 
    for (int i = 0; i < elements.length; i++) {  // 后续 999 次 求长度 <- local 
        sum += elements[i]; // 1000 次取下标 i 的元素 <- local 
    } 
}

可以节省 999 次 Field 读取操作

但如果 doSum 方法没有内联,则不会进行上面的优化

(2)反射优化

public class Reflect1 {
    public static void foo() {
        System.out.println("foo...");
    }

    public static void main(String[] args) throws Exception {
        Method foo = Reflect1.class.getMethod("foo");
        for (int i = 0; i <= 16; i++) {
            System.out.printf("%d\t", i);
            foo.invoke(null);
        }
        System.in.read();
    }
}

foo.invoke 前面 0 ~ 15 次(膨胀阈值)调用使用的是 MethodAccessor 的 NativeMethodAccessorImpl 实现

NativeMethodAccessorImpl 源码:

class NativeMethodAccessorImpl extends MethodAccessorImpl {
    private final Method method;
    private DelegatingMethodAccessorImpl parent;
    private int numInvocations;

    NativeMethodAccessorImpl(Method var1) {
        this.method = var1;
    }

    public Object invoke(Object var1, Object[] var2) 
        throws IllegalArgumentException, InvocationTargetException {

        // inflationThreshold 膨胀阈值,默认 15
        if (++this.numInvocations > ReflectionFactory.inflationThreshold() 
            && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {

            // 使用 ASM 动态生成的新实现代替本地实现,速度较本地实现快 20 倍左右
            MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator())
                .generateMethod(this.method.getDeclaringClass(),
                                this.method.getName(), 
                                this.method.getParameterTypes(), 
                                this.method.getReturnType(),
                                this.method.getExceptionTypes(), 
                                this.method.getModifiers());
            this.parent.setDelegate(var3);
        }

        // 调用本地实现
        return invoke0(this.method, var1, var2);
    }

    void setParent(DelegatingMethodAccessorImpl var1) {
        this.parent = var1;
    }

    private static native Object invoke0(Method var0, Object var1, Object[] var2);
}

当调用到第 16 次(从0开始算)时,会采用运行时生成的类代替掉最初的实现,可以通过 debug 得到

类名为 sun.reflflect.GeneratedMethodAccessor1

可以使用阿里的 arthas 工具:

java -jar arthas-boot.jar

选择 1 回车表示分析该进程

再输入【jad + 类名】来进行反编译

注意

通过查看 ReflectionFactory 源码可知

  • sun.reflflect.noInflflation 可以用来禁用膨胀(直接生成 GeneratedMethodAccessor1,但首次生成比较耗时,如果仅反射调用一次,不划算)

  • sun.reflflect.inflflationThreshold 可以修改膨胀阈值

4、JMH 性能测试工具

JMH 基准测试请参考:http://openjdk.java.net/projects/code-tools/jmh/

JMH(Java Microbenchmark Harness)是用于代码微基准测试的工具套件,主要是基于方法层面的基准测试,精度可以达到纳秒级。该工具是由 Oracle 内部实现 JIT 的大牛们编写的,他们应该比任何人都了解 JIT 以及 JVM 对于基准测试的影响。

当你定位到热点方法,希望进一步优化方法性能的时候,就可以使用 JMH 对优化的结果进行量化的分析。

JMH 比较典型的应用场景如下:

  1. 想准确地知道某个方法需要执行多长时间,以及执行时间和输入之间的相关性
  2. 对比接口不同实现在给定条件下的吞吐量
  3. 查看多少百分比的请求在多长时间内完成

下面我们以字符串拼接的两种方法为例子使用 JMH 做基准测试。

(1)依赖

  <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.20</version>
  </dependency>
  <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.20</version>
    <scope>provided</scope>
  </dependency>

(2)使用

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;


import java.util.concurrent.TimeUnit;


@BenchmarkMode(Mode.AverageTime)     //使用模式  :是对吞吐量 平均响应时间等
@Warmup(iterations = 3)     //配置预热次数, 这里我们设置为3次 ,让Jvm该加载的都加载
//本例是一次运行5秒,总共运行3次
// 在性能对比时候,采用默认1秒即可,
@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(1) // 配置同时起多少个线程执行 ,也可以设置到方法上
@Fork(1)    //代表启动多个单独的进程分别测试每个方法,我们这里指定为每个方法启动一个进程
@OutputTimeUnit(TimeUnit.NANOSECONDS)   //OutputTimeUnit 统计结果的时间单位,这个例子的单位为 秒
public class JMHTestDemo {


    @Benchmark
    public void testStringAdd() {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 1000; i++) {
            sb.append(i);
        }
//        System.out.println( sb.toString());
    }

    @Benchmark
    public void testStringBuilderAdd() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 1000; i++) {
            sb.append(i);
        }
//        System.out.println( sb.toString());
    }


    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(JMHTestDemo.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}

(3)JMH 注解

@BenchmarkMode

用来配置 Mode 选项,可用于类或者方法上,这个注解的 value 是一个数组,可以把几种 Mode 集合在一起执行,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime}),还可以设置为 Mode.All,即全部执行一遍。

  1. Throughput:整体吞吐量,每秒执行了多少次调用,单位为 ops/time
  2. AverageTime:用的平均时间,每次操作的平均时间,单位为 time/op
  3. SampleTime:随机取样,最后输出取样结果的分布
  4. SingleShotTime:只运行一次,往往同时把 Warmup 次数设为 0,用于测试冷启动时的性能
  5. All:上面的所有模式都执行一次

@State

通过 State 可以指定一个对象的作用范围,JMH 根据 scope 来进行实例化和共享操作。@State 可以被继承使用,如果父类定义了该注解,子类则无需定义。由于 JMH 允许多线程同时执行测试,不同的选项含义如下:

  1. Scope.Benchmark:所有测试线程共享一个实例,测试有状态实例在多线程共享下的性能
  2. Scope.Group:同一个线程在同一个 group 里共享实例
  3. Scope.Thread:默认的 State,每个测试线程分配一个实例

@OutputTimeUnit

为统计结果的时间单位,可用于类或者方法注解

@Warmup

预热所需要配置的一些基本测试参数,可用于类或者方法上。一般前几次进行程序测试的时候都会比较慢,所以要让程序进行几轮预热,保证测试的准确性。参数如下所示:

  1. iterations:预热的次数
  2. time:每次预热的时间
  3. timeUnit:时间的单位,默认秒
  4. batchSize:批处理大小,每次操作调用几次方法

为什么需要预热?
因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译为机器码,从而提高执行速度,所以为了让 benchmark 的结果更加接近真实情况就需要进行预热。

@Measurement

实际调用方法所需要配置的一些基本测试参数,可用于类或者方法上,参数和 @Warmup 相同。

@Threads

每个进程中的测试线程,可用于类或者方法上。

@Fork

进行 fork 的次数,可用于类或者方法上。如果 fork 数是 2 的话,则 JMH 会 fork 出两个进程来进行测试。

@Param

指定某项参数的多种情况,特别适合用来测试一个函数在不同的参数输入的情况下的性能,只能作用在字段上,使用该注解必须定义 @State 注解。

在介绍完常用的注解后,让我们来看下 JMH 有哪些陷阱。

(4)JMH 陷阱

在使用 JMH 的过程中,一定要避免一些陷阱。

比如 JIT 优化中的死码消除,比如以下代码:

@Benchmark
public void testStringAdd(Blackhole blackhole) {
    String a = "";
    for (int i = 0; i < length; i++) {
        a += i;
    }
}

JVM 可能会认为变量 a 从来没有使用过,从而进行优化把整个方法内部代码移除掉,这就会影响测试结果。

JMH 提供了两种方式避免这种问题,一种是将这个变量作为方法返回值 return a,一种是通过 Blackhole 的 consume 来避免 JIT 的优化消除。

其他陷阱还有常量折叠与常量传播、永远不要在测试中写循环、使用 Fork 隔离多个测试方法、方法内联、伪共享与缓存行、分支预测、多线程测试等,感兴趣的可以阅读 https://github.com/lexburner/JMH-samples 了解全部的陷阱。

(5)idea 插件

同时也可以使用 idea 插件,可以通过 IDEA 安装 JMH 插件使 JMH 更容易实现基准测试,在 IDEA 中点击 File->Settings...->Plugins,然后搜索 jmh,选择安装 JMH plugin

这个插件可以让我们能够以 JUnit 相同的方式使用 JMH,主要功能如下:

  1. 自动生成带有 @Benchmark 的方法
  2. 像 JUnit 一样,运行单独的 Benchmark 方法
  3. 运行类中所有的 Benchmark 方法

比如可以通过右键点击 Generate...,选择操作 Generate JMH benchmark 就可以生成一个带有 @Benchmark 的方法。

还有将光标移动到方法声明并调用 Run 操作就运行一个单独的 Benchmark 方法。

将光标移到类名所在行,右键点击 Run 运行,该类下的所有被 @Benchmark 注解的方法都会被执行。

七、自定义类加载器

1、使用场景

为什么要自定义类加载器?

  • 隔离加载类

  • 修改类加载的方式

  • 扩展加载源

  • 防止源码泄漏

使用场景
  • 想加载非 classpath 随意路径中的类文件
  • 通过接口来使用实现,希望解耦时,常用在框架设计
  • 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器

在JDK1.2之前,在自定义类加载器时,总会去继承 ClassLoader 类并重写 loadClass () 方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖 loadClass () 方法,而是建议把自定义的类加载逻辑写在 findClass () 方法中

在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URLCIassLoader 类,这样就可以避免自己去编写 findclass () 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

2、实现步骤

  • 继承 ClassLoader 父类
  • 要遵从双亲委派机制,重写 findClass 方法(只有上一级没有找到指定类时才调用)
    • 不是重写 loadClass 方法,若是重写则不会走双亲委派机制
  • 读取类文件的字节码
  • 调用父类的 defineClass 方法来加载类
  • 使用者调用该类加载器的 loadClass 方法

(1)自定义类加载器

当我们的类涉及到一些安全的操作,或者我们想从网络或者其它地方加载类。这种情况,我们就会创建自定义的类加载器,重写findClass方法来完成这个特殊的加载逻辑。

下面的类加载器CustomClassLoader通过构造的方式传入文件路径。当我们要加载类时,它会调用loadClass方法从我们定义的类路径下读取字节流。

public class CustomClassLoader extends ClassLoader {
    private String classPath;
    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 先检查类是否已经被加载,不调用这个方法则会破坏双亲委派机制
        Class<?> cls = findLoadedClass(name);
        if (cls != null) {
            return cls;
        }

        try {
            // 如果类还未被加载,尝试使用父类加载器加载(不破坏双亲委派机制)
            cls = getParent().loadClass(name);
        } catch (ClassNotFoundException e) {
            // 父类加载器无法加载该类,那么就调用 findClass 尝试自己加载
            cls = findClass(name);
        }

        return cls;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 你的类加载逻辑...
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String className) {
        String fileName = getFileName(className);
        try {
            InputStream is = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = is.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String getFileName(String name) {
        int index = name.lastIndexOf('.');
        // 如果没有找到'.'则直接在classPath中查找
        if (index == -1) { 
            return classPath + name + ".class";
        } else {
            return classPath + name.substring(index + 1) + ".class";
        }
    }
}

(2)使用类加载器

// 把上篇文章的Building类放在桌面
CustomClassLoader customClassLoader = new CustomClassLoader("C:\\Users\\xxx\\Desktop\\");

try {
    // 使用自定义类加载器加载 Building 类,若你的包名不叫这个,请更换。
    Class<?> cls = Class.forName("org.kfaino.jvm.Building", true, customClassLoader);

    // 创建类的实例
    cls.newInstance();

    System.out.println("实例名:" + cls.getName() + " 被加载器:" + cls.getClassLoader() + "创建");

} catch (Exception e) {
    e.printStackTrace();
}

3、Java自带的类加载器

(1)从本机加载类和资源

当然,如果你想要从外部加载字节码文件,可以不必这么繁琐。JDK提供了一个功能更强大的URLClassLoader

// 把Building放在桌面
URL[] urls = new URL[] {new URL("file:C:\\Users\\xxx\\Desktop\\")};
URLClassLoader customClassLoader = new URLClassLoader(urls);

try {
    // 使用自定义类加载器加载 Building 类
    Class<?> cls = Class.forName("org.kfaino.jvm.Building", true, customClassLoader);

    // 创建类的实例
    cls.newInstance();

    System.out.println("实例名:" + cls.getName() + " 被加载器:" + cls.getClassLoader() + "创建");

} catch (Exception e) {
    e.printStackTrace();
}

(2)从网络URL加载类和资源

URL url = new URL("http://www.github.com/xxx/");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");

URL url1 = new URL("http://www.github.com/xxx1/");
URL url2 = new URL("http://www.github.com/xxx2/");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url1, url2});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");

(3)从JAR文件加载类和资源

File file = new File("/xxx/resources/");
URL url = file.toURI().toURL();
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
URL resourceUrl = urlClassLoader.getResource("xxx.properties");
InputStream stream = resourceUrl.openStream();
Properties properties = new Properties();
properties.load(stream);

4、自定义类加载器的注意事项

类加载器在类的加载过程中起着至关重要的作用。因此,在使用时,我们必须倍加警惕。接下来,让我们来看下哪些需要注意的问题:

(1)内存泄漏

长期存活的类加载器持有类的引用就会导致内存泄露。为了避免这个问题,我建议你在关键代码处适当使用如下代码,让旧的类加载器和类实例进行解绑:

// 释放对 ClassLoader 的引用,使其有可能被垃圾回收
classLoader = null;
System.gc();

当然,每个问题都需要我们针对性地分析。我这里只是提供可能导致内存泄漏的一个说法。实际上,引发内存泄漏的原因有很多,如果你在工作中遇到了这个问题,可以使用一些可视化分析工具来综合性的分析。

(2)不要轻易破坏双亲委派机制

双亲委派模型是为了保证Java核心类库的安全性。当然,我们也可以选择破坏双亲委派模型,前提是,你已考虑好这些风险并规避。

在上述代码中,我们没有违背双亲委派模型的原则。回顾一下我们在之前文章中提到的双亲委派模型的概念:在类加载的过程中,我们首先会让父类加载器进行加载,只有在父类加载器无法加载的情况下,我们才会使用自定义的类加载器进行加载。

(3)线程安全问题

如果我们在多线程中使用类加载器,可能会导致类被重复加载多次。除了会浪费资源外,还会导致我们一些静态初始化代码被执行多次,造成一些诡异的问题。我在上篇专栏中说到,解决线程安全的方式有多种。为了保险起见,你可以采用同步方案来解决它。

5、自定义类加载器使用场景

在上面的例子中,我为你展示如何从外部加载字节码文件。接下来,我们来看下还有哪些使用场景:

(1)安全检查

安全,是软件工程中永恒的话题。为了防止第三方的潜在干扰,我们通常在获取外部文件的同时,做一些过滤的机制。你看代码:

public class SecurityCheckingClassLoader extends ClassLoader {
    private static final String CLASS_NAME_PREFIX = "Safe";

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 加上你想要的安全校验逻辑
        if (!name.startsWith(CLASS_NAME_PREFIX)) {
            throw new ClassNotFoundException("不安全的类: " + name);
        }

        return super.loadClass(name);
    }
}

上面,我为你举了一个简单的例子。我在加载类方法loadClass前校验类名的前缀,如果你不是Safe开头的类,我们就不予放行。

(2)解密加密的类文件

网络环境充满不确定性,如果你选择从网络获取字节码文件,我建议你首先做好加密工作。既然是从外部获取文件,我们可以通过继承URLClassLoader来实现。代码如下:

import java.net.URL;
import java.net.URLClassLoader;

public class DecryptingURLClassLoader extends URLClassLoader {
    public DecryptingURLClassLoader(URL[] urls) {
        super(urls);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 获取字节码文件
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        }
        byte[] decryptedClassData = decrypt(classData);
        return defineClass(name, decryptedClassData, 0, decryptedClassData.length);
    }

    private byte[] loadClassData(String className) {
        // 从网络中获取字节码文件
    }

    private byte[] decrypt(byte[] classData) {
        // 解密字节码文件
    }
}

八、内存模型

0、简介

CPU 的运行并不是直接操作内存而是先把内存里的数据读取到缓存中,而内存在读写操作时会造成数据不一致的现象。

Jvm 规范中定义一种 JAVA 内存模型(java Memory Model,简称 JMM )来屏蔽各种硬件和操作系统的内存访问差异,以实现让 JAVA 程序在各种平台下都能达到一直的内存访问效果。JMM 是一种抽象的概念

image-20220717195152401

简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、原子性(三大特性)的规则和保障。

  • 原子性 - 保证指令不会受到线程上下文切换的影响

  • 可见性 - 保证指令不会受 cpu 缓存(工作内存)的影响

  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

工作内存:

工作内存(即上图中的高速缓存),系统中主内存共享变量数据修改被写入的时机是不确定的,多线程并发情况下很可能出现脏读,所以每个线程都有自己的工作内存,其中存储了该线程使用到的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也不能访问对方的工作内存,线程间变量值的传递只能通过主内存进行。

1、原子性

(1)问题

问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操

作。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令

getstatic i     // 获取静态变量i的值 
iconst_1         // 准备常量1 
iadd             // 加法 
putstatic i     // 将修改后的值存入静态变量i

而对应 i– 也是类似:

getstatic i     // 获取静态变量i的值 
iconst_1         // 准备常量1 
isub             // 减法
putstatic i     // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换:

image-20211106223250294

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

// 假设i的初始值为0 
getstatic i     // 线程1-获取静态变量i的值 线程内i=0 
iconst_1         // 线程1-准备常量1 
iadd             // 线程1-自增 线程内i=1 
putstatic i     // 线程1-将修改后的值存入静态变量i 静态变量i=1 
getstatic i     // 线程1-获取静态变量i的值 线程内i=1 
iconst_1         // 线程1-准备常量1 
isub             // 线程1-自减 线程内i=0 
putstatic i     // 线程1-将修改后的值存入静态变量i 静态变量i=0

但多线程下这 8 行代码可能交错运行(为什么会交错?思考一下):

线程是并发 不是并行(cpu是在多个线程之间来回执行)

出现负数的情况:

// 假设i的初始值为0 
getstatic i     // 线程1-获取静态变量i的值 线程内i=0 
getstatic i     // 线程2-获取静态变量i的值 线程内i=0 
iconst_1         // 线程1-准备常量1 
iadd             // 线程1-自增 线程内i=1 
putstatic i     // 线程1-将修改后的值存入静态变量i 静态变量i=1 
iconst_1         // 线程2-准备常量1 
isub             // 线程2-自减 线程内i=-1 
putstatic i     // 线程2-将修改后的值存入静态变量i 静态变量i=-1

出现正数的情况:

// 假设i的初始值为0 
getstatic i     // 线程1-获取静态变量i的值 线程内i=0 
getstatic i     // 线程2-获取静态变量i的值 线程内i=0 
iconst_1        // 线程1-准备常量1 
iadd             // 线程1-自增 线程内i=1 
iconst_1         // 线程2-准备常量1 
isub             // 线程2-自减 线程内i=-1 
putstatic i     // 线程2-将修改后的值存入静态变量i 静态变量i=-1 
putstatic i     // 线程1-将修改后的值存入静态变量i 静态变量i=1

(2)解决方法

java 是通过 synchronized 同步关键字来保证原子性,使用时应尽可能保证加锁代码的范围最小

synchronized 代码块

synchronized( 对象 ) { 
    要作为原子操作代码
}

synchronized 同步方法

public synchronized void test(){
    要作为原子操作代码 
}

2、可见性

(1)问题

public class Demo4_2 {

    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(run){
                // ....
            }
        });
        t.start();

        Thread.sleep(1000);
        run = false; // 线程t不会如预想的停下来
    }
}

当该代码执行后,经过一秒,主线程的 run 变量被修改为 false ,而此时子线程的循环却没有停下,一直执行。

(2)问题产生原因

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
image-20211107162621398
  1. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
image-20211107162658708
  1. 一秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
image-20211107162740199

线程读取共享变量的步骤:

1、将主内存中的共享变量拷贝到线程自己的工作内存中;

2、线程读取自己的工作内存中的共享变量副本;

3、线程修改变量后先存储到自己的工作内存;

4、最后由工作内存同步到主内存中;

(3)解决方法

volatile(易变关键字)

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。

上述问题只需用 volatile 修饰 run属性即可解决问题:

volatile static boolean run = true;

volatile 关键字体现了可见性,原理如下

  • 当子线程读取 run 变量时,会从主内存中读取
  • 当主线程修改 run 变量时,会立即刷新到主内存

(4)可见性

可见性:当一个线程修改了共享变量时,其他线程是否能立即感知到该更改

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况:

比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i– ,只能保证看到最新值,不能解决指令交错

注意

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点synchronized是属于重量级操作,性能相对更低

(5)扩展问题

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,为什么?

public class Demo4_2 {

    static boolean run = true;
    //volatile static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(run){
                // ....
                System.out.println(1);
            }
        });
        t.start();

        Thread.sleep(1000);
        run = false; 
    }
}

此时子线程中的循环会在一秒后停下,原因是 println() 的底层使用了 synchronized

    public void println(int x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点synchronized是属于重量级操作,性能相对更低

(6)happens-before(先行发生)

在 JMM 中,如果一个操作执行的结果需要对另一个操作可见或者代码重排序,那么这两个操作之间必须存在 happens-before(现行发生) 原则。

如果 JAVA 内存模型中所有的有序性仅靠 volatile 和 synchronized 来完成,那么有很多操作都将会变得非常繁琐,但是我们在编写 java 代码时并没有时时刻刻使用这两个关键字,这是因为 java 语言中的 JMM 原则下,有一个先行发生原则限制(规矩)。

happens-before 语义本质上是一种可见性

happens-before 总原则

  • 如果一个操作 happens-before 另一个操作,那么第一个操作将对第二个操作可见,而且第一个操作在第二个操作之前;
  • 两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原则制定的顺序来执行。如果重排序之后的结果与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法

下面介绍 happens-before 官网要求的 8 条规则

1)次序规则

一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作,即前一个操作的结果可以被后续操作获取

2)锁定规则

一个 unlock (解锁)操作先行发生于后面(指时间上的先后)对同一个锁的 lock (加锁)操作。

锁定规则与可重入锁冲突?

可重入实际上是对同一把锁,进行了(ObjectMonitor 中 count+1的操作),并不是对同一把锁,再加锁。

3)volatile变量规则

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的(后面指时间)

简单来说就是对 volatile 变量修改后,其他线程能立马感知到该修改

4)传递规则

如果操作 A 先行发生于 操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C

5)线程启动原则

Thread Start RuleThread 对象的 start() 方法先行发生于此线程的每一个动作

6)线程中断规则

Thread Interruption Rule

对线程 interrupt() 方法的调用先行发生于被中断的线程的代码检测到事件的发生;

简单来说就是要先调用 interrupt() 方法设置中断标志位,之后才能检测到中断

7)线程终止规则

Thread Termination Rule

线程中所有操作先行发生于对此线程的终止检测,我们可以通过 isAlive() 等手段检测线程是否已经终止执行

8)对象终结规则

Finalizer Rule

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()(垃圾回收) 方法的开始

简单来说就是对象没有完成初始化之前,不能调用 finalize() 方法

3、有序性

(0)有序性

有序性是指在单线程环境中, 程序是按序依次执行的.

而在多线程环境中, 程序的执行可能因为指令重排而出现乱序

注意:有序性只出现在多线程中

指令重排:指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序

(1)问题

int num = 0;
boolean ready = false; // 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
// 线程2 执行此方法
public void actor2(I_Result r) {
    num = 2;
    ready = true;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

  • 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

  • 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1

  • 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

  • 还有一种情况是0,这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行num = 2

出现0的这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现

(2)解决方法

volatile 修饰的变量,可以禁用指令重排

volatile boolean ready = false;

(3)案例

懒汉式单例:双重检验锁

public class DoubleChecked {
    private static DoubleChecked instance;

    // 私有化空参构造,防止产生多个对象
    private DoubleChecked(){};

    public static DoubleChecked getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (instance == null) {                         //Single Checked
            synchronized (DoubleChecked.class) {
                // 也许有其它线程已经创建实例,所以再判断一次
                if (instance == null) {                 //Double Checked
                    instance = new DoubleChecked();
                }
            }
        }
        return instance ;
    }
}

以上的实现特点是:

  • 懒惰实例化

  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁

但在多线程环境下,上面的代码是有问题的, instance = new DoubleChecked(); 对应的字节码为:

0: new #2 // class cn/itcast/jvm/t4/Singleton 
3: dup 
4: invokespecial #3 // Method "<init>":()V 
7: putstatic #4 // Field 
INSTANCE:Lcn/itcast/jvm/t4/Singleton;

其中 4 7 两步的顺序不是固定的,也许 jvm 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1,t2 按如下时间序列执行:

时间1 t1 线程执行到 instance = new DoubleChecked();
时间2 t1 线程分配空间,为DoubleChecked对象生成了引用地址(0 处) 
时间3 t1 线程将引用地址赋值给 instance,这时 instance != null(7 处) 
时间4 t2 线程进入getInstance() 方法,发现 instance != null(synchronized块外),直接返回 INSTANCE 
时间5 t1 线程执行DoubleChecked的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

(4)happens-before

happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x; 
static Object m = new Object(); 

new Thread(()->{ 
    synchronized(m) { 
        x = 10; 
    } 
},"t1").start(); 

new Thread(()->{ 
    synchronized(m) { 
        System.out.println(x); 
    } 
},"t2").start();
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x; 

new Thread(()->{ 
    x = 10; 
},"t1").start(); 

new Thread(()->{ 
    System.out.println(x); 
},"t2").start();
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x; 
x = 10; 

new Thread(()->{ 
    System.out.println(x); 
},"t2").start();
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或t1.join()等待它结束)
static int x; 

Thread t1 = new Thread(()->{ 
    x = 10; 
},"t1"); 

t1.start(); 
t1.join(); 
System.out.println(x);
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
static int x; 
public static void main(String[] args) { 

    Thread t2 = new Thread(()->{ 
        while(true) { 
            if(Thread.currentThread().isInterrupted()) { 
                System.out.println(x); 
                break; 
            } 
        } 
    },"t2"); t2.start(); 

    new Thread(()->{ 
        try {
            Thread.sleep(1000); 
        } catch (InterruptedException e) { 
            e.printStackTrace(); 
        }
        x = 10; 
        t2.interrupt(); 
    },"t1").start(); 

    while(!t2.isInterrupted()) { 
        Thread.yield(); 
    }
    System.out.println(x);
}
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

变量都是指成员变量或静态成员变量

4、CAS 与 原子类

(1)CAS

CAS 即 Compare and Swap(比较并交换) ,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作:

// 需要不断尝试 
while(true) { 
    int 旧值 = 共享变量 ; // 比如拿到了当前值 0 
    int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1 

    /*这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候 compareAndSwap 
    返回 false,重新尝试,直到: compareAndSwap 返回 true,
    表示我本线程做修改的同时,别的线程没有干扰 */
    if( compareAndSwap ( 旧值, 结果 )) { 
        // 成功,退出循环 
    } 
}

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。

  • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一

  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子

import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class TestCAS {
    public static void main(String[] args) throws InterruptedException {
        DataContainer dc = new DataContainer();
        int count = 5;
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < count; i++) {
                dc.increase();
            }
        });
        t1.start();
        t1.join();
        System.out.println(dc.getData());
    }
}

class DataContainer {
    private volatile int data;
    static final Unsafe unsafe;
    static final long DATA_OFFSET;

    static {
        try {
            // Unsafe 对象不能直接调用,只能通过反射获得
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            unsafe = (Unsafe) theUnsafe.get(null);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new Error(e);
        }
        try {
            // data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
            DATA_OFFSET = unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
        } catch (NoSuchFieldException e) {
            throw new Error(e);
        }
    }

    public void increase() {
        int oldValue;
        while (true) {
            // 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
            oldValue = data;
            // cas 尝试修改 data 为 旧值 + 1,如果期间旧值被别的线程改了,返回 false
            if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue + 1)) {
                return;
            }
        }
    }

    public void decrease() {
        int oldValue;
        while (true) {
            oldValue = data;
            if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue - 1)) {
                return;
            }
        }
    }
    public int getData() { 
        return data; 
    } 
}

(2)乐观锁和悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

(3)原子操作类

juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。

可以使用 AtomicInteger 改写之前的例子:

import java.util.concurrent.atomic.AtomicInteger;

public class Demo4_4 {
    // 创建原子整数对象
    private static AtomicInteger i = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i.getAndIncrement();  // 获取并且自增  i++
                //i.incrementAndGet();  // 自增并且获取  ++i
            }
        });

        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i.getAndDecrement(); // 获取并且自减  i--
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

5、synchronized 优化

Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的 哈希码 、 分代年龄 ,当加锁时,这些信息就根据情况被替换为 标记位 、 线程锁记录指 针 、 重量级锁指针 、 线程ID 等内容

(1)轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。这就好比:

学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。

如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。

而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来

假设有两个方法同步块,利用同一个对象加锁

static Object obj = new Object(); 
public static void method1() { 
    synchronized( obj ) { 
        // 同步块 A 
        method2(); 
    } 
}

public static void method2() { 
    synchronized( obj ) { 
        // 同步块 B 
    } 
}

每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

(2)锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

(3)重量级锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

自旋锁:“自旋”可以理解为“自我旋转”,这里的“旋转”指“循环”,比如 while 循环或者 for 循环。“自旋”就是自己在这里不停地循环,直到目标达成。而不像普通的锁那样,如果获取不到锁就进入阻塞

在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。

  • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)

  • Java 7 之后不能控制是否开启自旋功能

(4)偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)

  • 访问对象的 hashCode 也会撤销偏向锁

  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,

  • 重偏向会重置对象的 Thread ID

  • 撤销偏向和重偏向都是批量进行的,以类为单位

  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的

  • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

6、其他优化

(1)减少上锁时间

同步代码块中尽量短

(2)减少锁的粒度

将一个锁拆分为多个锁提高并发度,例如:

  • ConcurrentHashMap

  • LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值

  • LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高

(3)锁粗化

多次循环进入同步块不如同步块内多次循环

另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

new StringBuffer().append("a").append("b").append("c");

(4)锁消除

JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。

(5)读写分离

CopyOnWriteArrayList

ConyOnWriteSet


  目录