0X JavaSE-- 并发编程(ThreadGroup、JMM、volatile、synchronized、线程池)

ThreadGroup

线程组可以对线程进行批量控制。

  • 每个 Thread 必然存在于一个 ThreadGroup 中,Thread 不能独立于 ThreadGroup 存在。
  • 执行 main()方法的线程名字是 main。
  • 如果在 new Thread 时没有显式指定,那么默认将父线程(当前执行 new Thread 的线程)线程组设置为自己的线程组。
hread testThread = new Thread(() -> {
    System.out.println("testThread当前线程组名字:" +
            Thread.currentThread().getThreadGroup().getName());
    System.out.println("testThread线程名字:" +
            Thread.currentThread().getName());
});

testThread.start();
System.out.println("执行main所在线程的线程组名字: " + Thread.currentThread().getThreadGroup().getName());
System.out.println("执行main方法线程名字:" + Thread.currentThread().getName());
执行main所在线程的线程组名字: main
testThread当前线程组名字:main
testThread线程名字:Thread-0
执行main方法线程名字:main
  • ThreadGroup 是一个标准的向下引用的树状结构,父 ThreadGroup 可以引用其子 ThreadGroup 和子线程,但子 ThreadGroup 和子线程不能直接引用父 ThreadGroup,也不允许平级引用。这样设计可以防止"上级"线程被"下级"线程引用而无法被 GC 回收。
  • 线程组可以起到统一控制线程的优先级和检查线程权限的作用。

线程优先级

  • 线程优先级可以被人为指定,但 JVM只是给操作系统传达这个建议值,线程最终在操作系统中的优先级还是由操作系统决定。
    • 不过通常情况下,高优先级的线程将会比低优先级的线程有更高的概率得到执行。

JMM Java内存模型

Java 内存模型(Java Memory Model,JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。

作用

Java 线程之间的通信由 JMM 控制。

Java内存模型和运行时内存区域

Java 内存模型(JMM)和 Java 运行时内存区域的区别可大着呢。

Java 内存模型(Java Memory Model,JMM)

  • 定义:Java 内存模型描述了多线程程序中变量(包括实例字段、静态字段和数组元素)如何从主内存写入和读取,以及如何在主内存和工作内存(线程本地内存)之间传递。
  • 主要内容:
    • 可见性(Visibility): 当一个线程修改了某个变量的值,其他线程是否能立即看到这个修改。、
    • 原子性(Atomicity): 一个操作是不可分割的,不会被其他线程的操作所中断。
    • 有序性(Ordering): 程序执行的顺序是否与代码中的顺序一致。
  • 几个关键:
    • 主内存和工作内存: 所有变量都存储在主内存中,每个线程还有自己的工作内存,线程对变量的所有操作(读取和赋值)都必须在工作内存中进行。
    • volatile 关键字: 保证变量的可见性和有序性,但不保证原子性。
    • synchronized 关键字: 保证进入临界区的线程对变量的可见性和原子性。
    • happens-before 原则: 如果一个操作 happens-before 另一个操作,那么第一个操作的结果对第二个操作是可见的,且第一个操作的执行顺序排在第二个操作之前。

Java Runtime Memory Areas Java 运行时内存区域
在这里插入图片描述

  • 定义:JVM 在运行时将内存分为若干个不同的区域,用来存储不同类型的数据。这些区域包括:
    • 程序计数器(Program Counter Register):每个线程都有一个独立的程序计数器,用于记录当前线程执行的字节码指令的地址。
    • Java 虚拟机栈(JVM Stack):每个线程都有一个独立的虚拟机栈,用于存储局部变量表、操作数栈、动态链接和方法出口等信息。
    • 本地方法栈(Native Method Stack):与 Java 虚拟机栈类似,不过它服务于虚拟机使用到的本地方法(如 JNI)。
    • (Heap):堆是所有线程共享的一块内存区域,几乎所有的对象实例都在这里分配。堆是垃圾收集器管理的主要区域,根据垃圾收集器的不同,可以细分为新生代(Eden、From Survivor、To Survivor)和老年代。
    • 方法区(Method Area):方法区是所有线程共享的一块内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。在 JDK 8 之后,方法区称为元空间(Metaspace)。
    • 运行时常量池(Runtime Constant Pool):运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
共享堆内存带来的问题

对每一个线程来说,栈都是私有的,而堆是共有的。

也就是说,在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量。

所以,内存可见性针对的是堆中的共享变量。
在这里插入图片描述
根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取。
因此,各线程在本地内存中修改了共享变量,必须再写回到主内存,其它线程才能看到,如果还没写回到主内存,其它线程就访问了这个共享变量,就会造成可见性问题。

volatile关键字就是解决这个问题的。
对于可能被多线程并发修改访问的共享变量,加上 volatile关键字后,能确保:在本地内存修改的共享变量,立刻在主内存中生效。
用术语来说就是:保证多线程操作共享变量的可见性以及禁止指令重排序

在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员更方便地理解,设计者提出了 happens-before 的概念(下文会细讲),它更加简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则,以及这些规则的具体实现方法。

指令重排序

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
因为指令重排序可以提高性能。

处理器中普遍采用流水线技术,
但是流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。

指令重排就是减少中断的一种技术。

  • 指令重排一般分为以下三种:
    • 编译器优化重排。编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
    • 指令并行重排。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
    • 内存系统重排。由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

程序可分为正确同步(使用 Volatile、Synchronized 等关键)、未正确同步的。

  • 对于已经正确同步的程序

    • 会改变程序执行结果的重排序,JMM 要求编译器和处理器都禁止这种重排序。
    • 不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求,允许这种重排序。
  • 对于未正确同步的程序

    • JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。(被后发线程先改变)

并发编程的两个问题

  • 并发编程的线程之间存在两个问题:
    • 线程间如何通信?即:线程之间以何种机制来交换信息
    • 线程间如何同步?即:线程以何种机制来控制不同线程间发生的相对顺序
  • 有两种并发模型可以解决这两个问题:
    • 消息传递并发模型
    • 共享内存并发模型

这两种模型之间的区别如下图所示:
在这里插入图片描述

Java 内存模型(JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。
Java 内存模型(JMM)主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括方法区、堆、栈、本地方法栈、程序计数器。
指令重排是为了提高 CPU 性能,但是可能会导致一些问题,比如多线程环境下的内存可见性问题。
happens-before 规则是 JMM 提供的强大的内存可见性保证,只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。

volatile

  • volatile 作用
    • 保证可见性:当写一个 volatile 变量时,JMM 会把该线程在本地内存中的变量强制刷新到主内存中去;
    • 禁止指令重排
    • 但不保证原子性:
  • volatile 的实际应用
    • 在单例模式中,双重检查锁定(Double-Checked Locking)使用 volatile 确保线程安全地创建单例实例。
    • volatile 适用于需要在多个线程间共享并及时更新的状态变量,例如计数器、标志变量。

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码就能发现,加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

volatile 会禁止指令重排

重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。
使用 volatile 关键字修饰共享变量可以禁止这种重排序。

当使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:

  • 写屏障(Write Barrier):当一个 volatile变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
  • 读屏障(Read Barrier):当读取一个 volatile变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。

“也就是说,编译器和处理器执行到 volatile 变量时,必须将其前面的所有语句都必须执行完,后面所有得语句都未执行。且前面语句的结果对 volatile 变量及其后面语句可见。”

public class VolatileExample {
    private volatile boolean running = true;

    public void start() {
        Thread worker = new Thread(() -> {
            while (running) {
                // 执行一些工作
            }
            System.out.println("Thread stopped.");
        });
        worker.start();
    }

    public void stop() {
        running = false;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        example.start();
        
        Thread.sleep(1000); // 主线程等待一段时间
        example.stop();     // 停止工作线程
    }
}

volatile 不适用的场景

  • volatile 不适用于非原子操作。如自增、自减。
public class VolatileProblemDemo {
    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[100];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    // 这里直接使用volatile变量进行自增操作,没有额外的同步控制
                    count++;
                }
            });
        }

        for (Thread t : threads) {
            t.start();
        }

        // 确保主线程等待所有子线程都执行完毕后,最后才执行输出语句
        for (Thread t : threads) {
            t.join();
        }

        System.out.println(count);// out: 不能保证每次都是 10000.
    }
}

解决办法:

  • 把 inc++ 拎出来单独加 synchronized 关键字:
	public int inc = 0;
	public synchronized void increase() {
   	 	inc++;
	}
  • 通过重入锁 ReentrantLock 对 inc++ 加锁
public int inc = 0;
Lock lock = new ReentrantLock();
public void increase() {
    lock.lock();
    inc++;
    lock.unlock();
}
  • 原子类 AtomicInteger
public AtomicInteger inc = new AtomicInteger();
public void increase() {
    inc.getAndIncrement();
}

synchronized

  • synchronized 的作用:

    • 保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作)
    • 保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 功能)。
  • synchronized 的本质:使锁机制生效,从此线程必须竞争获得加了 synchronized关键字的资源的使用权。

  • synchronized 关键字最主要有以下 3 种应用方式:

    • 同步方法:为调用该方法的对象实例(即this引用)加锁;保证在任意时刻,只有一个线程能执行该方法;
    • 同步静态方法:为该方法所属类的Class对象加锁,;
    • 同步代码块:可以指定给哪个对象加锁。这可以是任何对象,最常见的是方法内的一个对象实例或者类的 Class对象指定加锁对象。
  • Java 多线程的锁都是基于对象的

    • Class 对象是一种特殊的 Java 对象,代表了程序中的类和接口。Java 中的每个类型(包括类、接口、数组以及基础类型)在 JVM 中都有一个唯一的 Class 对象与之对应。这个 Class 对象被创建的时机是在 JVM 加载类时,由 JVM 自动完成。
      Class 对象中包含了与类相关的很多信息,如类的名称、类的父类、类实现的接口、类的构造方法、类的方法、类的字段等等。这些信息通常被称为元数据(metadata)。
      通过 Class 对象来获取类的元数据,甚至动态地创建类的实例、调用类的方法、访问类的字段等,就是Java 的反射(Reflection)机制。

所以我们常说的类锁,其实就是 Class 对象的锁。

synchronized 同步实例方法

同步方法:为调用该方法的对象实例加锁;保证在任意时刻,只有一个线程能执行该方法;

其实在 volatile 对变量自增的修复中,已经演示了:

public class Test {

	public int inc = 0;
	public synchronized void increase() {
   	 	inc++;
	}

    public static void main(String[] args) throws InterruptedException {
        final Test test = new Test();
        Thread[] threads = new Thread[100];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                test.increase();
                }
            });
        }

        for (Thread t : threads) {
            t.start();
        }

        // 确保主线程等待所有子线程都执行完毕后,最后才执行输出语句
        for (Thread t : threads) {
            t.join();
        }

        System.out.println(count);// out: 10000
    }
}

如果在方法 increase() 前不加 synchronized,因为 i++ 不具备原子性,所以最终结果总会小于目标值。


synchronized 同步静态方法

首先:每次新建实例时,实例方法都会被拷贝一份;静态方法则存在于一个公共区域。

我们重申:synchronized 同步实例方法时,锁住的是调用该方法的实例。

  • 线程1 运行实例A,实例A 调用同步实例方法 increase。此时
    • 其它线程无法访问实例A 的所有同步实例方法;
    • 其它线程可以访问实例A 的所有非同步方法。
    • 其它线程可以通过实例B 调用同步实例方法 increase,而无需等待锁。

如果希望对所有实例都能共享的值加以同步,应将其声明为静态方法。
如果希望对每个实例分别独享的值加以同步,应将其声明为实例方法

例如,对于一个自增的计数器方法,所有新建的变量都能操作,那么应声明为静态方法。

synchronized 同步代码块

同步会带来一定的性能开销,因此需要合理使用。
不应将整个方法或者更大范围的代码块做同步,而应尽可能地缩小同步范围,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹。

public class AccountingSync implements Runnable {
    static AccountingSync instance = new AccountingSync(); // 饿汉单例模式

    static int i=0;

    @Override
    public void run() {
        //省略其他耗时操作....
        //使用同步代码块对变量i进行同步操作,锁对象为instance
        synchronized(instance){
            for(int j=0;j<1000;j++){
                i++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);// out: 2000
    }
}

我们将 synchronized 作用于一个给定的实例对象 instance,即当前实例对象就是锁的对象,当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 instance 实例对象的锁,如果当前有其他线程正持有该对象锁,那么新的线程就必须等待,这样就保证了每次只有一个线程执行 i++ 操作。

synchronized 的锁

synchronized 属于悲观锁。

在 JVM 的早期版本中,synchronized 是重量级的,因为线程阻塞和唤醒需要操作系统的介入。但在 JVM 的后续版本中,对 synchronized 进行了大量优化,如偏向锁、轻量级锁和适应性自旋等,所以现在的 synchronized 并不一定是重量级的,其性能在许多情况下都很好,可以大胆地用。

synchronized 属于可重入锁

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的其它临界资源时,这种情况属于重入锁,请求将会成功。

synchronized 就是可重入锁,因此一个线程调用 synchronized 方法的同时,在其方法体内部调用该对象另一个 synchronized 方法是允许的,如下:

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    static int j=0;

    @Override
    public void run() {
        for(int j=0;j<1000;j++){
            //this,当前实例对象锁
            synchronized(this){
                i++;
                increase();//synchronized的可重入性
            }
        }
    }

    public synchronized void increase(){
        j++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}

锁的四种状态

在 J6 以前,所有的锁都是”重量级“锁,因为使用的是操作系统的互斥锁

当一个线程持有锁时,其他试图进入 synchronized块的线程将被阻塞,直到锁被释放。
涉及到了线程上下文切换和用户态与内核态的切换,因此效率较低。

为了减少获得锁和释放锁带来的性能消耗,J6 引入了“偏向锁”和“轻量级锁” 的概念,对 synchronized 做了一次重大的升级。

J6 之前大家对 synchronized 的印象就是慢,所以 J6 对 synchronized 做了大量的优化。主要有:
自旋锁 Spinlock
适应性自旋 Adaptive Spinning
锁消除 Lock Elimination
锁粗化 Lock Coarsening
偏向锁 Biased Locking
轻量级锁 Lightweight Locking

自旋锁以及它的进阶版适应性自旋优化针对的场景是,大部分时候线程持锁的时间是比较短暂的,我们没必要让线程使用重量级的系统调用来等待唤醒,可以增加一些空循环,说不定就能获取到锁了。普通自旋锁这个空循环的次数是固定的,适应性自旋会根据运行时的信息,来动态调整空循环的次数。

锁消除与锁粗化比较简单,就是有时候一个对象根本不会被并发使用,所以完全没必要去做加锁的操作,或者对于一个锁连续的加锁解锁可以合并成一次加锁解锁。

在 J6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World(Java 垃圾回收中的一个重要概念,JVM 篇会细讲)期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。
在这里插入图片描述

Biased Lock 偏向锁

  • 什么时候使用偏向锁?
    • 资源在大部分情况下,都由同一线程多次获取。即不存在锁竞争的情况,也就没有线程阻塞。
    • 持有偏向锁的线程将永远不需要触发同步,消除了同步语句、CAS (Compare And Swap)操作

大白话就是对锁设置个变量,如果发现为 true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为 false,代表存在其他线程竞争资源,那么就会走后面的流程。

偏向锁实际上就是「锁对象」潜意识「偏向」同一个线程来访问,让锁对象记住这个线程 ID,当线程再次获取锁时,亮出身份,如果是同一个 ID 直接获取锁就好了,是一种 load-and-test 的过程,相较 CAS 又轻量级了一些。

Java 对象头

Java 对象头最多由三部分构成:

  • MarkWord
  • ClassMetadata Address
  • Array Length (如果对象是数组才会有这部分)

其中 Markword 是保存锁状态的关键
对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。
想在一个对象中表示这么多信息自然就要用位来存储,在 64 位操作系统中,是这样存储的(注意颜色标记)
在这里插入图片描述

偏向锁逐步被弃用

偏向锁给 JVM 增加了巨大的复杂性,维护成本很高

最终就是,J15 之前,偏向锁默认是 enabled,从 J15 开始,默认就是 disabled,除非显式地通过 UseBiasedLocking 开启。

偏向锁的实现原理

  • 第一次进入同步块:
    • 当线程第一次进入同步块时,如果这个对象的锁是偏向锁状态,JVM 会在对象头的 Mark Word 中记录该线程的 ID,并将栈帧中的锁记录(Lock Record)也更新为偏向锁的状态。
  • 再次进入同步块:
    • 当该线程再次进入这个同步块时,它会检查对象头的 Mark Word 中的线程ID:
    • 如果是当前线程的 ID:
      • 说明当前线程已经持有锁,因此无需进行任何 CAS操作即可再次进入同步块。
    • 如果不是当前线程的 ID:
      • 说明有其他线程尝试竞争这个偏向锁。此时,需要进行 CAS操作来尝试将 Mark Word 中的线程 ID 替换为当前线程的 ID。具体有两种情况:
        • CAS操作成功:说明之前的线程不再持有这个锁,Mark Word中的线程ID被成功替换为当前线程的ID,锁依然保持为偏向锁。
        • CAS操作失败:说明之前的线程依然持有这个锁。此时,JVM会暂停之前的线程,将偏向锁标识位设置为0,并将锁标志位设置为00,将锁升级为轻量级锁。
          在这里插入图片描述

撤销偏向锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
唤醒被停止的线程,将当前锁升级成轻量级锁。

所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:
-XX:UseBiasedLocking=false

轻量级锁

  • 什么时候使用轻量级锁?
    • 多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。

然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋:不断尝试去获取锁,一般用循环来实现。

自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。

但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和 JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。

轻量级锁的释放

在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。
在这里插入图片描述

重量级锁

如果 CPU 通过 CAS 就能处理好加锁/释放锁,这样就不会有上下文的切换。
但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,

升级成重量级锁,阻塞线程排队竞争,线程阻塞期间不执行 CAS操作自然不占用 CPU 资源

重量级锁依赖于操作系统的互斥锁(mutex,用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段) 实现,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

锁的升级流程

每一个线程在准备获取共享资源时:

  1. 检查 MarkWord 里面是不是放的自己的 ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
  2. 如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空。
  3. 两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作,把锁对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。
  4. 第三步中成功执行 CAS 的获得资源,失败的则进入自旋 。
  5. 自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
  6. 进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。
在这里插入图片描述

CAS 和乐观锁

  • synchronized 是悲观锁,线程开始执行第一步就要获取锁,一旦获得锁,其他的线程进入后就会阻塞并等待锁。
  • CAS: Compare and Swap,是一种乐观锁的实现方式,是一种无锁的原子操作。乐观锁,线程执行的时候不会加锁,它会假设此时没有冲突,然后完成某项操作;如果因为冲突失败了就重试,直到成功为止。
  • 必须根据实际情况,选择使用 synchronized 的锁还是 CAS操作。

用于在硬件层面上提供原子性操作。在某些处理器架构(如x86)中,比较并交换通过指令 CMPXCHG 实现通过比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

CAS 属于乐观锁,是 Java 对于乐观锁的一种实现方式

乐观锁与悲观锁

  • 悲观锁:总是假设每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

    • 悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
  • 乐观锁:总是假设使用数据时不会有别的线程修改数据,所以不会加锁,只是在更新数据的时候会去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行的安全性。

    • 由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁。
    • 乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;

什么是 CAS

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作

在 CAS 中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

  • 判断 V 是否等于 E,
    • 如果等于,将 V 的值设置为 N;
    • 如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。
    • 这里的预期值 E 本质上指的是“旧值”。

我们以一个简单的例子来解释这个过程:

如果有一个多个线程共享的变量i 原本等于 5,我现在在线程A 中,想把它设置为新的值 6;
我们使用 CAS 来做这个事情;
首先我们用 i 去与 5 对比,发现它等于 5,说明没有被其它线程改过,那我就把它设置为新的值 6,此次 CAS 成功,i 的值被设置成了 6;
如果不等于 5,说明 i 被其它线程改过了(比如现在 i 的值为 2),那么我就什么也不做,此次 CAS 失败,i的值仍然为 2。
在这个例子中,i就是 V,5 就是 E,6 就是 N。

那有没有可能我在判断了i为 5 之后,正准备更新它的新值的时候,被其它线程更改了i的值呢?
不会的。因为 CAS 是一种原子操作,它是一种系统原语,是一条 CPU 的原子指令,从 CPU 层面已经保证它的原子性。

CAS 的问题

ABA 问题
所谓的 ABA 问题,就是一个值原来是 A,变成了 B,又变回了 A。这个时候使用 CAS 是检查不出变化的,但实际上却被更新了两次。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。从 JDK 1.5 开始,JDK 的 atomic 包里提供了一个 AtomicStampedReference类来解决 ABA 问题。

这个类的 compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用 CAS 设置为新的值和标志。

长时间自旋
CAS 多与自旋结合。如果自旋 CAS 长时间不成功,会占用大量的 CPU 资源。

解决思路是让 JVM 支持处理器提供的pause 指令。

pause 指令能让自旋失败时 cpu 睡眠一小段时间再继续自旋,从而使得读操作的频率降低很多,为解决内存顺序冲突而导致的 CPU 流水线重排的代价也会小很多。

无法确保多个共享变量的原子操作
当对一个共享变量执行操作时,CAS 能够保证该变量的原子性。但是对于多个共享变量,CAS 就无法保证操作的原子性。这意味着,如果多个线程同时尝试更新不同的共享变量,即使每个更新都是原子的,整个状态的更新序列可能不是原子的。

这时通常有两种做法:

  • 使用AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行 CAS 操作;
  • 使用锁。锁内的临界区代码可以保证只有当前线程能操作。

CAS 的实现

  • OS 中的 CAS操作依赖于现代处理器指令集,通过底层的 CMPXCHG指令实现。
    • CAS(V,O,N)核心思想为:若当前变量实际值V 与期望的旧值O 相同,则表明该变量没被其他线程进行修改,因此可以安全的将新值 N 赋值给变量;若当前变量实际 V 与期望的旧值O 不相同,则表明该变量已经被其他线程做了处理,此时将新值N 赋给变量操作就是不安全的,在进行重试。
  • 在并发容器中,CAS 是通过 sun.misc.Unsafe类实现的,该类提供了一些可以直接操控内存和线程的底层操作,可以理解为 Java 中的“指针”。

其它的锁

前面提到,synchronized 内部实现了三把锁。这三把锁对程序员是透明的,synchronized 封装好了许多细节,尽管这让使用变得更方便了,但也存在一些缺点:

  • synchronized 不支持并发读。但实际上多个线程并发读是安全的,也是提高效率的一个方法。
  • synchronized 无法知道线程有没有成功获取到锁。
  • synchronized 没有忙则等待机制,获取到锁的线程如果阻塞了,不会让出锁。

实际上,在 java.util.concurrent(JUC)包下,还为我们提供了更多的锁类和锁接口(尤其是子包 locks 下),它们有更强大的功能或更牛逼的性能。
在这里插入图片描述

自旋锁 VS 适应性自旋锁

阻塞或唤醒一个线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
因此,如果我们相信锁马上就要被别的线程释放,不妨先不要阻塞线程,而是让线程在占据 CPU 的同时,继续申请锁。

满足这个运行模式,就是自旋锁。自旋锁的实现原理同样也是 CAS

从 J6 开始,自旋锁是默认开启的。

但自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。

因此引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功的,进而它将允许自旋等待更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
在这里插入图片描述

可重入锁和非可重入锁

可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提:锁的是同一个对象或者 class)。

ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点就是可以一定程度避免死锁。

/**
 * 类中的两个方法都是被内置锁 synchronized 修饰的,doSomething()方法中调用了doOthers()方法。
 * 因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
 */
public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

如果是一个不可重入锁,那么当前线程在调用 doOthers() 之前,需要将执行 doSomething() 时获取当前对象的锁释放掉,实际上该对象锁已经被当前线程所持有,且无法释放。所以此时会出现死锁。

可重入和不可重入的底层原理

首先ReentrantLock和 NonReentrantLock 都继承了父类AQS,其父类 AQS 中维护了一个同步状态 status 来计数重入次数,status 初始值为 0。

当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果status == 0表示没有其他线程在执行同步代码,则把 status 置为 1,当前线程开始执行。如果status != 0,则判断当前线程是否获取到了这个锁,如果是的话执行status+1,且当前线程可以再次获取锁。

而非可重入锁是直接获取并尝试更新当前 status 的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

释放锁时,可重入锁同样会先获取当前 status 的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将 status 置为 0,将锁释放。
在这里插入图片描述

公平锁与非公平锁

这里的“公平”,其实通俗意义来说就是“先来后到”,也就是 FIFO。如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。

一般情况下,非公平锁能提升一定的效率。但是非公平锁可能会发生线程饥饿(有一些线程长时间得不到锁)的情况。所以要根据实际的需求来选择非公平锁和公平锁。

ReentrantLock 支持非公平锁和公平锁两种。

独享锁(排他锁)、共享锁联合实现了读写锁

我们前面讲到的 synchronized 和后面要讲的 ReentrantLock,其实都是“独享锁”。
独享的意思是,不管线程要进行读还是写,这些锁在同一时刻只允许一个线程进行访问。

与之对应的,就是共享锁,指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

而读写锁可以在同一时刻允许多个读线程访问。Java 提供了 ReentrantReadWriteLock类作为读写锁的默认实现,内部维护了两个锁:一个读锁,一个写锁。通过分离读锁和写锁,使得在“读多写少”的环境下,大大地提高了性能。

在 ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync,但读锁和写锁的加锁方式不一样。
读锁是共享锁写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以 ReentrantReadWriteLock 的并发性相比一般的互斥锁有了很大提升。

ReentrantLock

Constructor
// 无参构造器默认构造一个 非公平锁,也即是默认参数为 false.
ReentrantLock lock = new ReentrantLock();

// fair 参数为 true 时,创建一个 公平锁。
ReentrantLock lock = new ReentrantLock(boolean fair);
demo
  • 锁必须在 try 代码块开始之前获取
  • 加锁之前不能有异常抛出,
  • 锁必须手动释放,且必须于 finally块中释放。

错误示例:

Lock lock = new XxxLock();
// ...
try {
    // 如果在此抛出异常,会直接执行 finally 块的代码
    doSomething();
    // 不管锁是否成功,finally 块都会执行
    lock.lock();
    doOthers();

} finally {
    lock.unlock();
} 

正确示例:

Lock lock = new XxxLock();
// ...
lock.lock();
try {
    doSomething();
    doOthers();
} finally {
    lock.unlock();
}
synchronized 和 ReentrantLock
  • ReentrantLock 是一个类; synchronized 是 Java 中的关键字;
  • ReentrantLock 可以实现多路选择通知(可以绑定多个 Condition); synchronized 只能通过 wait 和 notify/notifyAll 方法唤醒一个线程或者唤醒全部线程(单路通知);
  • ReentrantLock 必须手动释放锁。通常需要在 finally 块中调用 unlock 方法以确保锁被正确释放; synchronized 会自动释放锁,当同步块执行完毕时,由 JVM 自动释放,不需要手动操作。
  • ReentrantLock 通常提供更好的性能,特别是在高竞争环境下;synchronized 性能可能稍差一些,但随着 JDK 版本的升级,性能差距已经不大了。

ReentrantReadWriteLock 读写锁

  • ReentrantReadWriteLock 是 ReadWriteLock接口的默认实现。特点是可重入的、支持非公平锁和公平锁、在接口的功能之上,额外增加了读写锁的功能。
  • 相比于 ReentrantLock,
  • ReentrantReadWriteLock 的小弊端:在“写”操作的时候,其它线程不能写也不能读。我们称这种现象为“写饥饿”
/** 
 * 我们定义了一个 SharedResource类,该类使用 ReentrantReadWriteLock 来保护其内部数据。
 * write方法获取写锁,并更新共享数据。read方法获取读锁,并读取共享数据。
 * 在 main 方法中,我们创建了两个读线程和一个写线程。由于 ReentrantReadWriteLock 允许多个读取操作同时进行,
 * 因此读线程可以同时运行。然而,写入操作会被串行化,并且在写入操作进行时,读取操作将被阻塞。
 */
public class SharedResource {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private int data = 0;

    public void write(int value) {
        lock.writeLock().lock(); // 获取写锁
        try {
            data = value;
            System.out.println("写 " + Thread.currentThread().getName() + ": " + data);
        } finally {
            lock.writeLock().unlock(); // 释放写锁
        }
    }

    public void read() {
        lock.readLock().lock(); // 获取读锁
        try {
            System.out.println("读 " + Thread.currentThread().getName() + ": " + data);
        } finally {
            lock.readLock().unlock(); // 释放读锁
        }
    }

    public static void main(String[] args) {
        SharedResource sharedResource = new SharedResource();

        // 创建读线程
        Thread readThread1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                sharedResource.read();
            }
        });

        Thread readThread2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                sharedResource.read();
            }
        });

        // 创建写线程
        Thread writeThread = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                sharedResource.write(i);
            }
        });

        readThread1.start();
        readThread2.start();
        writeThread.start();

        try {
            readThread1.join();
            readThread2.join();
            writeThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

从执行结果可看出,读线程是并行执行的。
但是到写线程,就是串行执行了。
在这里插入图片描述

StampedLock 性能最好的锁

StampedLock 类是 J8 发布的,有人称它为锁的性能之王。

  • StampedLock 没有实现 Lock 接口和 ReadWriteLock 接口,但它实现了“读写锁”的功能,并且性能比 ReentrantReadWriteLock 更高。
  • StampedLock 还把读锁分为了“乐观读锁”和“悲观读锁”两种。
  • StampedLock 不会发生“写饥饿”的现象。
    • 它的核心思想在于,在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作。这种模式也就是典型的无锁编程思想,和 CAS 自旋的思想一样。这种操作方式决定了 StampedLock 在读多写少的场景下非常适用,同时还避免了写饥饿情况的发生。
  • StampedLock 是可重入的
  • 总的来说,StampedLock 的性能是非常优异的,在不考虑 API 更加复杂的情况下,基本上可以取代 ReentrantReadWriteLock
    • 乐观读锁:StampedLock 提供了乐观读锁机制,允许一个线程在没有任何写入操作发生的情况下读取数据,从而提高了性能。ReentrantReadWriteLock 没有提供这样的机制。
    • 锁降级:StampedLock 提供了从写锁到读锁的降级功能,这在某些场景下可以提供额外的灵活性。ReentrantReadWriteLock 不直接提供这样的功能。
class Point {
   private double x, y;
   private final StampedLock sl = new StampedLock();

   // 写锁的使用
   void move(double deltaX, double deltaY) {
     long stamp = sl.writeLock(); // 获取写锁
     try {
       x += deltaX;
       y += deltaY;
     } finally {
       sl.unlockWrite(stamp); // 释放写锁
     }
   }

   // 乐观读锁的使用
   double distanceFromOrigin() {
     long stamp = sl.tryOptimisticRead(); // 获取乐观读锁
     double currentX = x, currentY = y;
     if (!sl.validate(stamp)) { // //检查乐观读锁后是否有其他写锁发生,有则返回false
        stamp = sl.readLock(); // 获取一个悲观读锁
        try {
          currentX = x;
          currentY = y;
        } finally {
           sl.unlockRead(stamp); // 释放悲观读锁
        }
     }
     return Math.sqrt(currentX * currentX + currentY * currentY);
   }

   // 悲观读锁以及读锁升级写锁的使用
   void moveIfAtOrigin(double newX, double newY) {
     long stamp = sl.readLock(); // 悲观读锁
     try {
       while (x == 0.0 && y == 0.0) {
         // 读锁尝试转换为写锁:转换成功后相当于获取了写锁,转换失败相当于有写锁被占用
         long ws = sl.tryConvertToWriteLock(stamp);

         if (ws != 0L) { // 如果转换成功
           stamp = ws; // 读锁的票据更新为写锁的
           x = newX;
           y = newY;
           break;
         }
         else { // 如果转换失败
           sl.unlockRead(stamp); // 释放读锁
           stamp = sl.writeLock(); // 强制获取写锁
         }
       }
     } finally {
       sl.unlock(stamp); // 释放所有锁
     }
   }
}
// StampLock 和 ReentrantReadWriteLock 的使用对比
private final ReentrantReadWriteLock rrwLock = new ReentrantReadWriteLock();
    private int data = 0;

    public void write(int value) {
        rrwLock.writeLock().lock();
        try {
            data = value;
        } finally {
            rrwLock.writeLock().unlock();
        }
    }

    public int read() {
        rrwLock.readLock().lock();
        try {
            return data;
        } finally {
            rrwLock.readLock().unlock();
        }
    }

------------------------------------------------------------------------------
    private final StampedLock sLock = new StampedLock();
    private int data = 0;

    public void write(int value) {
        long stamp = sLock.writeLock();
        try {
            data = value;
        } finally {
            sLock.unlockWrite(stamp);
        }
    }

    public int read() {
        long stamp = sLock.tryOptimisticRead();
        int currentData = data;
        if (!sLock.validate(stamp)) {
            stamp = sLock.readLock();
            try {
                currentData = data;
            } finally {
                sLock.unlockRead(stamp);
            }
        }
        return currentData;
    }
  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值