浅谈 JAVA 并发编程的有锁和无锁机制

507 阅读16分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

本篇文章是笔者在梳理并发编程相关技术时的笔记整理,原先的目的是期望研究关于 JVM 中对于无锁机制的基本原理,在此过程中因为各技术点之间关联牵扯较多,因此还是先将 JVM 锁机制抛出作为一个引子,进而再通过对比分析的方式来解释无锁机制的实现思路。本文主要包括 3 个部分:

  • 1、从计算机操作系统的角度来看并发的问题
  • 2、JVM 中对于并发编程的支持
  • 3、从有锁到无锁的过程演进

本文在组织上不会严格按照这 3 个部分进行,但整体的内容会按照这三块来组织。

从并发问题说起

在深入理解计算机系统一书中有一段对于并发的定义:逻辑控制流在时间上重叠。目前我们主要将并发看做是一种操作系统内核用来运行多个应用程序的机制,但是并发不仅仅局限于内核,在应用程序中也同样扮演重要角色。现代操作系统提供了三种基本的构造并发程序的方法:

  • 进程:用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信 (interprocess communication, IPC)机制。
  • I/0 多路复用:在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
  • 线程:线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,而像I/0 多路 复用流一样共享同一个虚拟地址空间。

这里笔者只关注在 线程 这个点上;因为本篇主要是探讨锁和无锁机制的问题,因此这里必须先抛出多线程场景下存在的线程安全问题。在 《深入 JVM 虚拟机》一书中,对于线程安全的描述是:

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

简单说就是在多线程同时访问一个资源时,线程间依照某种方式访问资源,访问的结果总是能获取到正确的结果。事实证明,这很难做到。在实际的业务场景中,多线程对于共享变量的访问和修改往往会出现很多不可控的结果,这种不可控的结果往往由操作系统的调度逻辑有关,程序员一般不能在运行时干预。因此多线程对于共享资源的访问如果期望能够按照程序约定的方式执行,就需要使用额外的一些辅助手段,也就是实现线程安全的一些手段,包括:无状态化、不可变变量、线程私有化、同步集合、原子化对象、同步方法以及资源加锁。下面笔者主要从加锁的角度来介绍,来看看 JVM 中对于并发编程的支持。

JVM 中的并发编程支持

JAVA 中我们提到并发编程基本是围绕 JUC 来说,JUC 是 java.util.concurrent 包的缩写,JUC 提供了多种用于多线程编程和并发控制的接口和类,其主要包括 5 大类组件:

  • 锁:如 ReentrantLock、ReentrantReadWriteLock、StampedLock
  • 线程池:如 ThreadPoolExecutor
  • 原子类:如 tomicInteger、AtomicLong、AtomicRefrence
  • 同步器:如 Semaphore、CountDownLatch、CyclicBarrier
  • 并发容器:如 ConcurrentHashMap、BlockingQueue、CopyOnWriteArrayList

本篇中主要是探讨锁的机制,因此在下面的篇幅中主要是基于 ReentrantLock、ReentrantReadWriteLock、StampedLock 这几个类展开。但是在此之前笔者还需要从最基础的 Object 类提供的线程同步机制和 Synchronize 来先暖暖场。

synchronized 关键字和 Object 类

我们知道,JAVA 中所有的类都是 Object 类的子类,因此也都继承了 Object 类的方法,如:toString、equals、hashCode 等,当然也包括本节接下来要提到的关系锁和线程通信相关的 waitnotify/notifyAll 方法。这里笔者先提供一个线程交叉打印奇偶数的示例代码:

/**
 * @Classname ObjectLockTest
 * @Description ObjectLockTest
 * @Date 2024/8/12 10:17
 * @Created by glmapper
 */
public class ObjectLockTest {

    // 实现两个线程交互打印奇偶数
    private static int count = 0;
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        printNotify();
    }
    
    public static void printNotify() {
        new Thread(() -> {
            while (count < 10) {
                synchronized (lock) {
                    // 如果是奇数,等待
                    if ((count & 1) != 0) {
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println(Thread.currentThread().getName() + " num: " + count++);
                    lock.notify();
                }
            }
        }, "偶数").start();

        new Thread(() -> {
            while (count < 10) {
                synchronized (lock) {
                    // 如果是偶数,等待
                    if ((count & 1) != 1) {
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println(Thread.currentThread().getName() + " num: " + count++);
                    // 执行完之后通知偶数线程
                    lock.notify();
                }
            }
        }, "奇数").start();
    }
}

执行结果是:

偶数 num: 0
奇数 num: 1
偶数 num: 2
奇数 num: 3
偶数 num: 4
奇数 num: 5
偶数 num: 6
奇数 num: 7
偶数 num: 8
奇数 num: 9
偶数 num: 10

在上个 case 中,两个线程通过 JAVA 中的 wait(),notify()方法相互通信。wait()方法使当前线程等待,直到另一个线程对该对象调用notify()或notifyAll()方法。 notify()方法唤醒一个正在该对象的监视器上等待的线程notifyAll()方法唤醒在该对象的监视器上等待的所有线程。 线程通过调用其中一个wait()方法在对象的监视器上等待。 如果当前线程不是对象监视器的所有者,则这些方法可以引发IllegalMonitorStateException。

关于 notify() 和 notifyAll()

notify()方法用于唤醒一个在该对象上等待的线程。如果有多个线程在同一对象的监视器上等待,那么其中一个线程会被唤醒,但具体唤醒哪一个线程是由JVM决定的,不可预知。而notifyAll()方法会唤醒所有在该对象上等待的线程,这些被唤醒的线程会争夺该对象的锁,只有获取到锁的线程才能继续执行,其他线程会继续等待。下面通过一个案例来直观的体验下。

  • 通过一个 notifyAll 唤醒多个线程
/**
 * @Classname NotifyAllTest
 * @Description NotifyAllTest
 * @Date 2024/8/12 14:47
 * @Created by glmapper
 */
public class NotifyAllTest {
    // 锁对象
    private static final Object lock = new Object();
    // 等待条件
    private static boolean condition = false;

    /**
     * 用于测试 notifyAll 方法
     * 1. 创建三个线程,线程2、线程3、线程4
     * 2. 线程2、线程3、线程4等待condition条件
     * 3. 线程1等待3秒后,唤醒所有等待的线程
     * 4. 线程2、线程3、线程4被唤醒后,获取到锁并完成执行
     * @param args
     */
    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lock) {
                if (!condition) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() + "获取到锁并完成执行,通知其他线程");
            }
        }, "线程2").start();

        new Thread(() -> {
            synchronized (lock) {
                if (!condition) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() + "获取到锁并完成执行,通知其他线程");
            }
        }, "线程3").start();

        new Thread(() -> {
            synchronized (lock) {
                if (!condition) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() + "获取到锁并完成执行,通知其他线程");
            }
        }, "线程4").start();


        // 用于唤醒其他所有线程
        new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock) {
                condition = true;
                System.out.println("notify 通知其他线程唤醒");
              	// 换成 notify 之后则会出现锁等待问题
                lock.notifyAll();
            }
        }, "线程1").start();
    }
}
  • 如果将线程1 中的 lock.notifyAll() 换成 lock.notify() 则会出现锁等待

    > notify 通知其他线程唤醒
    > 线程2获取到锁并完成执行,通知其他线程
    

    实际上线程2 在获取锁并且执行之后,没有继续执行 notify ,因此线程3线程 4 会一直处于 等待状态;使用 jstack 查看线程栈信息如下:

    "线程3" #24 [28163] prio=5 os_prio=31 cpu=0.07ms elapsed=21.90s tid=0x0000000123010c00 nid=28163 in Object.wait()  [0x00000001706fa000]
       java.lang.Thread.State: WAITING (on object monitor)
    	at java.lang.Object.wait0(java.base@21.0.2/Native Method)
    	- waiting on <0x000000070fab43a8> (a java.lang.Object)
    	at java.lang.Object.wait(java.base@21.0.2/Object.java:366)
    	at java.lang.Object.wait(java.base@21.0.2/Object.java:339)
    	at com.glmapper.lock.NotifyAllTest.lambda$main$1(NotifyAllTest.java:41)
    	- locked <0x000000070fab43a8> (a java.lang.Object)
    	at com.glmapper.lock.NotifyAllTest$$Lambda/0x0000007001002c18.run(Unknown Source)
    	at java.lang.Thread.runWith(java.base@21.0.2/Thread.java:1596)
    	at java.lang.Thread.run(java.base@21.0.2/Thread.java:1583)
    
    "线程4" #25 [25859] prio=5 os_prio=31 cpu=0.03ms elapsed=21.90s tid=0x000000013110d400 nid=25859 in Object.wait()  [0x0000000170906000]
       java.lang.Thread.State: WAITING (on object monitor)
    	at java.lang.Object.wait0(java.base@21.0.2/Native Method)
    	- waiting on <0x000000070fab43a8> (a java.lang.Object)
    	at java.lang.Object.wait(java.base@21.0.2/Object.java:366)
    	at java.lang.Object.wait(java.base@21.0.2/Object.java:339)
    	at com.glmapper.lock.NotifyAllTest.lambda$main$2(NotifyAllTest.java:54)
    	- locked <0x000000070fab43a8> (a java.lang.Object)
    	at com.glmapper.lock.NotifyAllTest$$Lambda/0x0000007001003000.run(Unknown Source)
    	at java.lang.Thread.runWith(java.base@21.0.2/Thread.java:1596)
    	at java.lang.Thread.run(java.base@21.0.2/Thread.java:1583)
    

关于 IllegalMonitorStateException

这里笔者将 IllegalMonitorStateException 类贴出来

/**
 * Thrown to indicate that a thread has attempted to wait on an
 * object's monitor or to notify other threads waiting on an object's
 * monitor without owning the specified monitor.
 *
 * @author  unascribed
 * @see     java.lang.Object#notify()
 * @see     java.lang.Object#notifyAll()
 * @see     java.lang.Object#wait()
 * @see     java.lang.Object#wait(long)
 * @see     java.lang.Object#wait(long, int)
 * @since   JDK1.0
 */
public
class IllegalMonitorStateException extends RuntimeException {
    private static final long serialVersionUID = 3713306369498869069L;

    /**
     * Constructs an <code>IllegalMonitorStateException</code> with no
     * detail message.
     */
    public IllegalMonitorStateException() {
        super();
    }

    /**
     * Constructs an <code>IllegalMonitorStateException</code> with the
     * specified detail message.
     *
     * @param   s   the detail message.
     */
    public IllegalMonitorStateException(String s) {
        super(s);
    }
}

简单翻译下:“抛出此异常表明线程试图在未拥有指定监视器的情况下等待对象的监视器或通知正在等待该对象监视器的其他线程。”

换个说法就是: 如果当前线程不是对象监视器的所有者,则这些方法可以引发 IllegalMonitorStateException,先看下面的例子:

public class IllegalMonitorStateExceptionTest {
    // 锁对象
    private static final Object lock = new Object();
    public static void main(String[] args) throws InterruptedException {
        lock.wait();
    }
}

笔者在 main 方法中直接执行 lock.wait 方法,执行结果如下:

Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.wait0(Native Method)
	at java.base/java.lang.Object.wait(Object.java:366)
	at java.base/java.lang.Object.wait(Object.java:339)
	at com.glmapper.lock.IllegalMonitorStateExceptionTest.main(IllegalMonitorStateExceptionTest.java:13)

报错信息中提到当前线程不是 owner(线程所有权者),这里其实语义有点不明确;按照异常类中的注释信息,其核心在于拥有指定监视器。当一个线程需要执行同步代码块或同步方法时,必须首先获得对象的监视器锁。这意味着线程“拥有”了该对象的监视器,从而能够进入同步代码块或方法,并保证其他线程在此期间无法进入该同步区域。那如何拥有监视器呢?

  • 当一个线程执行 synchronized 关键字修饰的代码块或方法时,它会尝试获取该对象的监视器锁。
  • 一旦线程获得了监视器锁,其他线程在试图进入相同的 synchronized 代码块或方法时会被阻塞,直到当前持有锁的线程释放监视器锁。

语言级别的锁

前面笔者主要介绍的是 synchronized 关键字和 Object 类相互协作实现线程同步和资源互斥的访问。本节主要介绍 JAVA 语言中在 api 中提工的锁机制。主要包括:ReentrantLockReentrantReadWriteLockStampedLock。首先来解释下为什么已经有 ReentrantLock 了,JAVA 还提供 ReentrantReadWriteLock。这里有个非常关键的点在于ReentrantLock 是一个独占锁,这意味着无论是读操作还是写操作,锁都是独占的,其他线程无法同时进行读写操作;这样就导致在读操作非常频繁的情况下,系统的并发性受到限制,因为即使没有数据被修改,其他线程的读操作也会被阻塞。从这个角度来看,JAVA 提供 ReentrantReadWriteLock 主要是来解决一些特定场景下的并发性能问题,尤其是读多写少的情况。ReentrantReadWriteLock 的设计思路是,通过分离读锁和写锁,允许多个线程同时持有读锁而不会相互阻塞。只有在写操作发生时,才会独占写锁,阻塞其他读操作和写操作。

再来看StampedLockStampedLockReentrantReadWriteLock 主要区别在于 StampedLock 提供了乐观读,也就是使用了无锁机制。从 JAVA 的版本演进来看,StampedLock是 JDK 1.8 中提供的,虽然说 Jdk1.5 开始引进提供了 java.util.concurrent.atomic 包,但是到 1.8 才完成了对整个原子类和无锁机制的完善。所以可以说, StampedLock 是在基于 无锁机制(或者说是客观锁)的基础上,用来进一步提高在高并发读场景中性能的。

下面笔者仅提供一个 ReentrantLock 的使用样例模板:

public class ReentrantLockTest {
    static ReentrantLock lock = new ReentrantLock();
    public static void main(String[] args) {
        System.out.println("是否已经上锁:" + lock.isLocked());
        lock.lock();
        try {
            // 需要同步的代码
            System.out.println("是否已经上锁:" + lock.isLocked());
        } finally {
            lock.unlock();
            System.out.println("是否已经上锁:" + lock.isLocked());
        }
    }
}

执行结果如下:

是否已经上锁:false
是否已经上锁:true
是否已经上锁:false

在锁机制的实现上,ReentrantLock 与前面提到的 synchronized 是经常放在一块来比较的;关于这部分网上的资料已经非常多了,笔者不再赘述。那既然锁的实现方式有对标,那线程间的通信是不是也有类似 Object 类 wait 和 notify/notifyAll 方法呢?答案是有的。

Condition

Condition 是在 java 1.5 中才出现的,它用来替代传统的 Object 的 wait()、notify() 实现线程间的协作,相比使用 Object 的 wait()、notify(),使用 Condition 的 await()、signal() 这种方式实现线程间协作更加安全和高效。主要表现在以下几块:

  • 1、传统的 Object 的 wait()、notify() 实现线程间的协作方式,每个对象只有一个条件队列,这意味着如果多个条件需要协作,就必须使用多个对象锁,增加了复杂性。
  • 2、使用 notify() 时,无法控制唤醒哪一个线程,这可能导致唤醒不适合的线程,降低了效率。
  • 3、如果开发者对锁的管理不当,容易出现死锁或者线程无法正常唤醒的情况(如前面使用 nitify 唤醒导致线程等待的案例)

Condition 提供了多条件队列,即一个 Lock 对象可以创建多个 Condition 对象,每个 Condition 都有自己的等待队列,这使得线程可以根据不同的条件进行等待和通知;Condition 在明确的等待和通知机制上更加精准,可以避免传统方法中可能出现的唤醒错误线程的问题。下面是笔者使用 Condition+ ReentrantLock 实现的交互打印奇偶数的案例。

/**
 * @Classname ConditionTest
 * @Description 使用 Condition 和 ReentrantLock 实现两个线程交互打印奇偶数
 * @Date 2024/8/19 14:18
 * @Created by glmapper
 */
public class ConditionTest {

    // 创建一个 ReentrantLock 锁对象
    static ReentrantLock lock = new ReentrantLock();
    // 创建两个 Condition 对象
    static Condition oddCondition = lock.newCondition();
    static Condition evenCondition = lock.newCondition();

    public static void main(String[] args) {
        printCondition();
    }
    
    public static void printCondition() {
        // 创建一个计数器
        AtomicInteger count = new AtomicInteger(0);
        // 创建一个线程打印奇数
        new Thread(() -> {
            while (count.get() < 10) {
                lock.lock();
                try {
                    // 如果是偶数,等待
                    if ((count.get() & 1) == 0) {
                        try {
                            oddCondition.await();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println(Thread.currentThread().getName() + " num: " + count.getAndIncrement());
                    // 执行完之后通知偶数线程
                    evenCondition.signal();
                } finally {
                    lock.unlock();
                }
            }
        }, "奇数").start();

        // 创建一个线程打印偶数
        new Thread(() -> {
            while (count.get() < 10) {
                lock.lock();
                try {
                    // 如果是奇数,等待
                    if ((count.get() & 1) == 1) {
                        try {
                            evenCondition.await();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println(Thread.currentThread().getName() + " num: " + count.getAndIncrement());
                    // 执行完之后通知奇数线程
                    oddCondition.signal();
                } finally {
                    lock.unlock();
                }
            }
        }, "偶数").start();
    }
}

从编程的模式上来看,其实和 synchronized + Object 的代码逻辑很类似,只不过是使用了不同的关键字或者是类。内在的区别在于synchronized 就相当于整个锁对象中只有一个单一的 Condition 对象,所有的线程都注册在这个对象上。

无锁

在 JAVA 中,实现无锁机制的本质是通过原子操作和内存模型来避免使用传统的锁(如 synchronizedReentrantLock 等)进行线程同步,从而实现高效的并发控制。无锁机制的关键在于通过硬件提供的原子性操作,结合 JAVA 的内存模型,确保在多线程环境下数据的一致性和安全性。提到无锁机制,想必各位一定对自旋不陌生;实际上无锁算法的实现通常就是基于 CAS 和 自旋(反复尝试直到成功)来实现的。

这里笔者想继续深入剖析一下自旋;自旋的核心思想是在某个条件不满足时,线程会在一个小循环中持续检查该条件,而不是进入阻塞状态等待条件满足。自旋锁的优点是避免了线程上下文切换的开销,但也可能导致 CPU 资源的浪费,特别是在竞争激烈的情况下,因此并不是所有的场景都合适使用乐观锁,如果锁竞争激励,传统的 synchronizedReentrantLock 可能表现会更好。下面笔者给一个典型的自旋的代码片段:

new Thread(() -> {
    for (int j = 0; j < 1000; j++) {
        // 自旋实现无锁的递增操作
        int prev, next;
        do {
            prev = count.get();     // 获取当前值
            next = prev + 1;         // 计算下一个值
        } while (!count.compareAndSet(prev, next)); // 尝试设置新的值,如果失败则继续自旋
    }
}).start();

下面这段代码是笔者从 Unsafe 类中截取的

// AtomicInteger.class
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}
// Unsafe.class
public final int getAndSetInt(Object var1, long var2, int var4) {
  int var5;
  do {
     var5 = this.getIntVolatile(var1, var2);
  } while(!this.compareAndSwapInt(var1, var2, var5, var4));

  return var5;
}

关于无锁机制的实现远远要比笔者上面提到的那个简单。但从上面的定义中,我们就可以挖掘出诸如:原子操作Compare-And-Swap(CAS)JAVA 内存模型 以及无锁算法等概念。笔者尝试用一句话阐述下这个逻辑(个人观点):无锁机制通过原子操作(如 CAS)在多线程环境中直接在硬件层面修改共享变量,结合 JAVA 内存模型(JMM)确保线程间内存可见性和顺序性,从而实现高效的无锁算法,避免锁的开销和线程阻塞。

从有锁到无锁

本质上来说,从有锁到无锁,是 JAVA 发展过程中对于在高并发场景下提高性能、减少锁的开销,并简化并发编程的复杂性的过程,在高并发应用中,锁的开销会导致性能瓶颈;无锁机制的引入,一方面是通过减少锁竞争和线程阻塞,显著提升了系统的吞吐量和响应时间;另一方面也使得并发编程更加高效、灵活,减少了因锁引发的死锁、饥饿等问题。

总结

本篇主要介绍了 JAVA 中关于锁和线程通信协作相关技术点,并通过一些案例进行了直观化的表述。笔者从并发问题作为切入,从有锁到无锁,从 JAVA 原生的协作到语言 API 层面的协作,大体梳理和介绍了他们的基本原理和使用模版;本篇文章并非深入到底层源码级别,只是从思路上提供一个大题的轮廓,希望对各位读者有所帮助。