Java virtual machine : 二

类加载与字节码技术

类加载阶段

1. 加载

  • 将类的字节码载入方法区中,内部采用 C++ instanceKlass 描述 java 类,它的重要 field 有:
    • _java_mirror java 的类镜像,例如对 String 来说,就是 String.class ,作用是把 klass 暴露给 java 使用
    • _super 即父类
    • _fields 即成员变量
    • _methods 即方法
    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法表
  • 如果这个类还有父类没有加载,先加载父类
  • 加载和链接可能是交替运行的
instanceKlass 这样的【元数据】是存储在方法区( 1.8 后的元空间内),但 _java_mirror
是存储在堆中


2. 链接 

验证

在这个阶段会验证类是否符合JVM规范,并进行安全性检查。

如果对class文件中的魔数进行了修改,那么会出现如下报错:

E:\git\jvm\out\production\jvm>java cn.itcast.jvm.t5.HelloWorld
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value
3405691578 in class file cn/itcast/jvm/t5/HelloWorld
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at
java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
...
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
准备

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

  • static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
解析

将常量池中的符号引用解析为直接引用

例如

public class Person {
    private String name;
    private int age;

    public String getName() {
        return name;
    }
}

在编译时,如果我们在另一个类中使用了 getName 方法,那么编译器会生成一个符号引用,表示对 Person.getName() 方法的引用。这个引用是抽象的,不包含具体的内存地址。

在解析阶段,Java虚拟机会将这个符号引用翻译成运行时可用的引用。具体步骤包括:

  1. 定位 Person 类: Java虚拟机会查找并定位到 Person 类的字节码文件,以便在内存中加载它。

  2. 找到 getName 方法: Java虚拟机会在 Person 类中找到 getName 方法,并确定它的内存地址或偏移量。

  3. 将符号引用转化为直接引用: 解析阶段将符号引用 Person.getName() 转化为一个直接引用,该引用指向 getName 方法的内存地址或偏移量,以便在运行时可以直接调用该方法。

这个过程确保了在运行时,Java虚拟机可以正确地找到并执行 Person.getName() 方法,而不会出现引用错误。解析阶段的主要目的是确保符号引用能够顺利转化为直接引用,从而保证程序的正确性和可执行性。


3. 初始化

初始化即调用 <cinit>()V ,虚拟机会保证这个类的『构造方法』的线程安全
初始化发生时机
概括的说,类初始化是【懒惰】的
  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

以下情况不会导致初始化

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

典型应用 - 完成懒惰初始化单例模式
public final class Singleton {
    private Singleton() { }
    // 内部类中保存单例
    private static class LazyHolder {
        static final Singleton INSTANCE = new Singleton();
    }
    // 第一次调用 getInstance 方法,才会导致内部类加载和初始化其静态成员
    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}
  1. Singleton 类被声明为final,意味着它不能被继承。

  2. private Singleton() { }:私有的构造函数确保该类无法被外部直接实例化。

  3. private static class LazyHolder { ... }:这是一个静态内部类。静态内部类不会在外部类加载时被初始化,只有当它被实际调用时才会被加载和初始化。这种延迟加载的特性使得它非常适合单例模式的实现。

  4. static final Singleton INSTANCE = new Singleton();:在LazyHolder静态内部类中创建了一个静态、final的Singleton实例。这个实例在类加载和初始化时被创建,因此是线程安全的。

  5. public static Singleton getInstance() { ... }:这是获取单例实例的静态方法。当该方法被调用时,它会返回LazyHolder.INSTANCE,触发LazyHolder类的加载和初始化,从而创建单例实例。由于类加载是线程安全的,所以该模式保证了在多线程环境下也能正确地返回单例实例。


类加载器

以JDK8 为例:

双亲委派机制:从下往上询问,从上往下加载

  • 当一个类加载器试图加载一个类时,首先会检查自己有没有加载这个类,如果有,则直接返回已加载的类。
  • 如果没有,则向上委托给父类加载器。父类加载器也会按照同样的方式检查是否已加载,如果没有,则再将请求委托给自己的父类加载器,一直向上委派,直到达到最顶层的Bootstrap ClassLoader
  • 如果所有的类加载器都没有加载过这个类,那么Bootstrap ClassLoader会尝试在自己的类加载路径中查找这个类并加载,如果没有找到便向下委派给Extension ClassLoader,一直到自定义类加载器

1. 启动类加载器

Bootstrap 类加载器加载类:

package cn.itcast.jvm.t3.load;

public class F {
    static {
        System.out.println("bootstrap F init");
    }
}

执行:

package cn.itcast.jvm.t3.load;
public class Load5_1 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.F");
        System.out.println(aClass.getClassLoader());
    }
}

输出:

E:\git\jvm\out\production\jvm>java -Xbootclasspath/a:.
cn.itcast.jvm.t3.load.Load5
bootstrap F init
null
  • Bootstrap ClassLoader是cpp编写,Java无法直接访问,因此返回为null
  • -Xbootclasspath 表示设置 bootclasspath
  • 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
  • 可以用这个办法替换核心类
    • java -Xbootclasspath:<new bootclasspath>
    • java -Xbootclasspath/a:<追加路径>(后追加
    • java -Xbootclasspath/p:<追加路径>(前追加

2. 扩展类加载器

 扩展类加载器主要用于加载位于 $JAVA_HOME/lib/ext 目录下的 JAR 文件和类。这个目录是用于存放Java标准库之外的扩展库的地方。

如果有两个同名的类,一个位于 Class Path下,另一个位于$JAVA_HOME/lib/ext下,那么加载结果会是什么?

package cn.itcast.jvm.t3.load;
public class G {
    static {
        System.out.println("classpath G init");
    }
}

执行

public class Load5_2 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.G");
        System.out.println(aClass.getClassLoader());
    }
}

输出:

classpath G init
sun.misc.Launcher$AppClassLoader@18b4aac2

将这个类稍做修改并打好jar包拷贝至JAVA_HOME/lib/ext

package cn.itcast.jvm.t3.load;

public class G {
    static {
        System.out.println("ext G init");
    }
}
E:\git\jvm\out\production\jvm>jar -cvf my.jar cn/itcast/jvm/t3/load/G.class
已添加清单
正在添加: cn/itcast/jvm/t3/load/G.class(输入 = 481) (输出 = 322)(压缩了 33%)

重新执行

输出:

ext G init
sun.misc.Launcher$ExtClassLoader@29453f44

因为双亲委派机制的缘故,Application ClassLoader类加载器首先会委派给自己的父类加载器Extension ClassLoader,然后ext检查自己是否加载了这个类。ext会在自己的类加载路径下找到这个类,于是直接返回。因此App类加载器也就没用上,返回的是ext类路径下的类。


3.双亲委派模式

当试图加载一个类时,会执行以下代码:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查该类是否已经加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 2. 有上级的话,委派上级 loadClass
                    c = parent.loadClass(name, false);
                } else {
                    // 3. 如果没有上级了(ExtClassLoader),则委派BootstrapClassLoader
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
            }

            if (c == null) {
                long t1 = System.nanoTime();
                // 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
                c = findClass(name);
                // 5. 记录耗时
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

工作流程:

  • Application ClassLoader会调用 findLoadClass() 方法,检查该类是否已加载,如果返回值为null,则进入下一步
    • 当返回值为 null 时,判断是否存在父类,如果有,则委派给父类加载器(这里是Extension ClassLoader)
      • Extension ClassLoader 调用 findLoadClass() 方法,检查该类是否已加载,如果返回值为null,则进入下一步
      • 当返回值为 null 时,判断是否存在父类。因为  Extension ClassLoader 的父类加载器无法直接被Java编译器访问,因此返回值为null,进入 else 分支,委派 Bootstrap Class Loader,调用 findBootstrapClassOrNull() 方法
        • findBootstrapClassOrNull() 方法是一个本地方法,它会在JAVA_HOME/jre/lib 目录下查找这个类,如果没有则返回null
      • 继续往下执行,Extension ClassLoader 发现仍然没有找到要加载的类(c 仍为 null),则会调用 findClass() 方法,试图在自己的类加载路径JAVA_HOME/jre/lib/ext 下查找这个类
        • 如果仍没有找到,则抛出 ClassNotFoundException
    • ClassNotFoundException 被 Application ClassLoader 捕捉到,因为 catch 块中内容为空,因此继续往下执行。c 仍未 null ,Application ClassLoader 调用自己的 findClass() 方法,在 ClassPath 类路径下查找这个类。
    • 如果找到便返回这个类,如果仍没有找到,继续抛出 ClassNotFoundException

4. 线程上下文类加载器

  • 线程上下文类加载器是从JDK1.2开始引用的,getContextClassLoader()与setContextClassLoader(ClassLoader cl),用来分别获取和设置上下文类加载器
  • 如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器.
  • java应用运行时的初始线程的上下文类加载器是应用程序类加载器.在线程中运行的代码可以通过该类加载器来加载类与资源
在JDBC中,需要加载Driver驱动,但即使不写
Class.forName("com.mysql.jdbc.Driver")

也能让 com.mysql.jdbc.Driver 正确加载,这是为什么?

首先,Java定义的JDBC接口中提供了DriverManager类,它的类加载器是Bootstrap ClassLoader,它会自动查找合适的驱动程序。Java定义的JDBC接口位于JDK的rt.jar中(java.sql包),因此这些接口会由Bootstrap ClassLoader进行加载。

而这个接口的实现,即数据库厂商提供的Driver驱动通常情况下位于 ClassPath 下,这超出了Bootstrap ClassLoader的加载范围,无法让它来加载。想要加载Driver驱动的话只能用 AppClassLoader 或者自定义类加载器。

但是这就违反了双亲委派机制,因为父类加载器无法委派给子类加载器。为此,Java提供了一个 ThreadContextClassLoader,即线程上下文类加载器。

在DriverManager.getConnection(“jdbc:mysql://localhost:3306/mytestdb”,“username”,“password”);方法中,调用 Thread.currentThread().getContextClassLoader();  使用了当前线程使用的类加载器(默认是AppClassLoader)去加载jar包中的Driver驱动。

这样做的好处就是契合了 SPI(Service Provider Interface) 的思想:开发者提供接口或抽象类,不同的实现提供者为这个接口提供具体的实现。

SPI工作原理

  1. 定义接口或抽象类:首先,开发者定义一个接口或抽象类,用于描述一组规范或服务。

  2. 提供实现:接着,不同的实现提供者编写符合接口或抽象类规范的具体实现。这些实现通常以独立的JAR文件或模块的形式存在。

  3. 配置文件:SPI机制通常需要一个配置文件,例如META-INF/services/目录下的配置文件,用于指定实现提供者的类名。这个文件的内容是一个实现类的全限定名列表。

  4. 运行时加载:应用程序在运行时可以使用SPI机制来加载指定的实现类,这些实现类会自动被实例化并注入到应用程序中。

在JDBC这个案例中,也就是Bootstrap ClassLoader 加载了Java提供的JDBC接口,然后调用线程上下文类加载器,返回底层去加载具体的数据库驱动实现。


5. 自定义类加载器

什么时候会用到自定义类加载器?
  • 想加载非 classpath 随意路径中的类文件
  • 都是通过接口来使用实现,希望解耦时,常用在框架设计
  • 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
自定义类加载器步骤:
  • 继承 ClassLoader 父类
  • 要遵从双亲委派机制,重写 findClass 方法
    • 注意不是重写 loadClass 方法,否则不会走双亲委派机制
  • 读取类文件的字节码
  • 调用父类的 defineClass 方法来加载类
  • 使用者调用该类加载器的 loadClass 方法

运行期优化

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

它的部分运行时间如下

可以看到,有明显的两次速度提升,从50000提升到20000,以及再次提升到800

之所以会这样,是因为

JVM 将执行状态分成了 5 个层次:
  • 0 层,解释执行(Interpreter
  • 1 层,使用 C1 即时编译器编译执行(不带 profiling
  • 2 层,使用 C1 即时编译器编译执行(带基本的 profiling
  • 3 层,使用 C1 即时编译器编译执行(带完全的 profiling
  • 4 层,使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的回边次数】等
即时编译器( JIT )与解释器的区别
  • 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
  • JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
  • 解释器是将字节码解释为针对所有平台都通用的机器码
  • JIT 会根据平台类型,生成平台特定的机器码
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2 ,总的目标是发现热点代码( hotspot 名称的由来),优化之
而最后阶段速度的大幅提升是因为采用了逃逸分析
逃逸分析(Escape Analysis)是一种用于优化内存分配的编译器和运行时技术。它的主要目标是分析对象的生命周期和作用域,以确定是否可以将对象分配在堆上(heap allocation)还是可以将其分配在栈上(stack allocation),以提高程序的性能和减少垃圾回收的压力。

方法内联
private static int square(final int i) {
    return i * i;
}
System.out.println(square(9));
如果发现 square 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置:
System.out.println(9 * 9);
还能够进行常量折叠( constant folding )的优:
System.out.println(81);

字段优化
字段优化(Field Optimization)是编译器和运行时系统中的一种优化技术,用于优化类的字段(成员变量)的存储和访问。字段优化的目标是减少内存占用、提高访问速度和降低数据结构的复杂性,从而提高程序的性能和资源利用率。
  1. 字段排列和对齐: 编译器可以对类的字段进行排列和对齐,以减少内存浪费和提高访问速度。例如,编译器可以将相同数据类型的字段放在一起,以减少填充字节。此外,对齐字段可以使其更容易地加载到寄存器中,从而提高访问速度。

  2. 字段压缩: 对于某些数据类型,编译器可以使用更紧凑的表示方式,以减少内存使用。例如,可以将布尔值压缩为1位而不是1字节。

  3. 字段内联: 编译器可以将字段内联到使用它们的方法中,从而减少字段访问的开销。这在某些情况下可以减少对对象引用的依赖,提高访问速度。

  4. 去除不必要的字段: 编译器可以检测和删除不再使用的字段,以减少对象的内存占用。

  5. 字段访问优化: 运行时系统可以通过使用寄存器等优化技术来加速字段的访问。这包括将字段加载到高速缓存中以提高访问速度。


2. 反射优化

反射优化是指通过改进和加速Java程序中的反射操作,以提高程序的性能。
  1. 缓存反射对象: 将反射操作的结果缓存起来,以避免重复的反射查找和调用。

  2. 使用反射池: 可以使用反射池(Reflection Pool)来重用反射对象,从而减少对象的创建和销毁开销。

  3. 直接访问字段和方法: 如果性能要求高,可以使用直接字段和方法访问,而不是反射调用。

  4. 避免不必要的反射: 尽量避免不必要的反射操作,可以通过设计良好的类和接口来减少反射的需求。

  5. 使用替代方案: 在某些情况下,可以考虑使用其他技术或框架来替代反射,例如使用代码生成或代理模式。


内存模型

1. Java内存模型

Java内存模型(Java Memory Model,JMM)是一种规范,用于定义多线程并发访问共享内存时的行为和规则。JMM规定了在多线程环境下,如何保证线程之间的可见性、原子性和有序性,以避免出现数据竞争和不确定性行为。

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的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换:

在单线程下,以上8条指令是顺序执行的,所以不会出现问题。

但是在多线程下,这8条指令有可能交错运行:

出现负数的情况:

// 假设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

而解决办法便是使用 synchronized 关键字进行上锁:

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

使用 synchronized 关键字可以解决并发问题

static int i = 0;
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
                i++;
            }
        }
    });

    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
                i--;
            }
        }
    });

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
}
如何理解呢:可以把 obj 想象成一个房间,线程 t1 t2 想象成两个人。
当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行
count++ 代码。
这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。
t1 执行完 synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。 t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count-- 代码。
注意:上例中 t1 t2 线程必须用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 m1 对象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。

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不会如预想的停下来
}

这个程序很有可能无法正常结束,因为main 线程对 run 变量的修改对于 t 线程不可见,从而导致了 t 线程无法停止。

对此程序进行分析:

1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
2. 因为 t 线程要频繁从主内存中读取 run 的值, JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
3. 1 秒之后, main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
那么如何解决呢?
只需要加上 volatile 关键字即可
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况
注意
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?
这是因为在System.out.println()中也使用了 synchronized 关键字

3 有序性

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

对于以上一段代码,可能的结果有几个?

情况 1 :线程 1 先执行,这时 ready = false ,所以进入 else 分支结果为 1
情况 2 :线程 2 先执行 num = 2 ,但没来得及执行 ready = true ,线程 1 执行,还是进入 else 分支,结果为1
情况 3 :线程 2 执行到 ready = true ,线程 1 执行,这回进入 if 分支,结果为 4 (因为 num 已经执行过了)
但这其中最重要的是情况4: 线程 2 执行 ready = true ,切换到线程 1 ,进入 if 分支,相加为 0 ,再切回线程 2 执行 num = 2
对这段代码进行大量并发测试,结果如下:

3.2 解决办法

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

对原代码中的 ready 变量使用 volatile 进行修饰后重新测试:

可以看到 0 的结果不再出现了。

3.3 有序性理解

JVM 会在不影响正确性的前提下,调整语句的执行顺序
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行
时,既可以是
i = ...; // 较为耗时的操作
j = ...;

也可以是

j = ...;
i = ...; // 较为耗时的操作
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性,例如著名的 double-checked locking 模式实现单例
public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                // 也许有其它线程已经创建实例,所以再判断一次
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
以上的实现特点是:
  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
但在多线程环境下,上面的代码是有问题的, INSTANCE = new Singleton() 对应的字节码为:
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 Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接
返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

4 CAS与原子类

4.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,所以线程不会陷入阻塞,这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

4.2 乐观锁与悲观锁

  1. 悲观锁(Pessimistic Locking): 悲观锁是一种悲观的假设,它认为在多线程环境下,资源会发生冲突,因此在访问资源之前会先对资源进行加锁,以防止其他线程同时访问。在悲观锁的机制下,当一个线程获得了锁后,其他线程必须等待锁被释放才能访问资源。典型的悲观锁实现包括数据库的行级锁和表级锁,以及Java中的synchronized关键字。

  2. 乐观锁(Optimistic Locking): 乐观锁是一种乐观的假设,它认为资源在多线程环境下不会经常发生冲突。因此,在访问资源时不会加锁,而是先尝试进行操作,然后在更新资源时检查是否有其他线程已经修改了资源。如果没有冲突,操作继续执行;如果发现冲突,就需要回滚操作或者重试。典型的乐观锁实现包括版本号控制和CAS(Compare and Swap)操作。

关键区别:

  • 悲观锁认为冲突是常态,所以在访问资源前先加锁,其他线程必须等待。
  • 乐观锁认为冲突是少数情况,所以不加锁,而是在更新时检查冲突。


4.3 原子操作类

juc java.util.concurrent )中提供了原子操作类,可以提供线程安全的操作,例如: AtomicInteger 、AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。
可以使用 AtomicInteger 改写之前的例子:
// 创建原子整数对象
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 等内容

5.1 轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。这就好比:
学生(线程 A )用课本占座,上了半节课,出门了( CPU 时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。
如果这期间有其它学生(线程 B )来了,会告知(线程 A )有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来。

5.2 锁膨胀

如果在尝试加轻量级锁的过程中, CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
static Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块
    }
}

流程:

  1. 线程1访问同步块,把 obj 对象头中的Mark复制到线程1的锁记录中
  2. CAS修改 Mark 为线程1的锁记录地址
  3. 成功加锁(轻量级锁)
  4. 线程1执行同步块
  5. 线程1执行同步块
    1. 此时,线程2访问同步块,并把 Mark 复制到线程2的锁记录中
  6. 线程1执行同步块
    1. CAS修改 Mark 为线程2的锁记录地址
  7. 线程1执行同步块
    1. 加锁失败
  8. 线程1执行同步块
    1. CAS修改 Mark 为重量级锁(由 00 变为 10 )
  9. 线程1执行同步块
    1. 线程2阻塞等待
  10. 线程1执行完毕
    1. 线程2阻塞等待
  11. 线程1解锁失败
    1. 线程2阻塞等待
  12. 线程1释放重量级锁,唤起阻塞线程竞争
    1. 线程2阻塞等待
  13. 线程2竞争重量级锁
  14. 线程2竞争成功(加锁)

5.3 重量锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
  • Java 7 之后不能控制是否开启自旋功能

自旋重试成功

自旋重试失败


5.4 偏向锁

偏向锁(Biased Locking)是Java中锁优化的一种机制,旨在减少低竞争情况下的锁操作的性能开销。它的核心思想是:在没有竞争的情况下,将锁偏向于第一个获取锁的线程,使得该线程后续再次进入同步块时不需要重新竞争锁,而是直接获得锁,从而提高了性能。

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW
  • 访问对象的 hashCode 也会撤销偏向锁
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2
  • 重偏向会重置对象的 Thread ID
  • 撤销偏向和重偏向都是批量进行的,以类为单位
  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
  • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

5.5 其他优化

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值