JUC 并发编程

JUC 并发编程

JUC 是 Java 5.0 之后新增的一个包 java.util.concurrent ,该包提供了一套并发编程的工具类,包括原子操作、线程池、Lock、Condition 等类,方便进行多线程编程的操作。

JUC 的出现是为了解决多线程共享资源,协作完成任务时常见的问题,如同时访问共享资源、线程死锁、饥饿、并行性不足等问题。使用 JUC 提供的工具类可以简化并发程序的编写,提高程序的效率和稳定性

涉及到 Java 多线程的基础知识此处不再总结,详细可以参考上一篇总结 Java 多线程基础

一、Lock 锁

Lock 锁相比 synchronized 锁,JUC 包中的 Lock 锁的功能更加强大,它提供了各种各样的锁(公平/非公平锁,读写锁,独占锁等),具有更好的性能和扩展性。

java.util.concurrent.locks.Lock 是一个接口,主要有三个实现:ReentrantLock、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WriteLock

接口方法:

  • void lock():加同步锁,如果没有得到锁会一直等
  • void unlock():释放同步锁
  • boolean tryLock():尝试获取锁。如果没有获取到则立即返回,不做任何等待
  • boolean tryLock(long time, TimeUnit unit):尝试获取锁,且等待指定时间
  • void lockInterruptibly():以可以被打断的方式加锁
  • Condition newCondition():获取用于线程间通信的 Condition 对象

使用 Lock 锁需要注意以下几点:

  • 使用 Lock 对象实现同步锁,要求各个线程使用的是同一个对象。
  • 确保锁被释放:使用 Lock API 实现同步操作,是一种面向对象的编码风格。这种风格有很大的灵活性,同时可以在常规操作的基础上附加更强大的功能。但是也要求编写代码更加谨慎,如果忘记调用 lock.unlock() 方法则锁不会被释放,从而造成程序运行出错。
  • 加锁和解锁操作对称执行:几层加锁操作就需要有几层解锁操作。
1. 可重入锁

可重入锁,又称为递归锁,是指是指线程在持有锁的情况下,可以继续重入这个锁。简单来说,线程可以反复获取已经持有的锁,而不会被自己所持有的锁拦截。在 Java 中,ReentrantLock 和 synchronized 都是可重入锁。

可重入锁通常使用一个计数器来记录当前线程重入该锁的次数,每次加锁时计数器加一,每次解锁时计数器减一,只有计数器为零时,锁才真正释放。这样,可重入锁可以防止死锁的发生,提高了线程的效率。

ReentrantLock 全类名:java.util.concurrent.locks.ReentrantLock。这是 Lock 接口最典型、最常用的一个实现类。

官方使用文档如下:

class X { 
    private final ReentrantLock lock = new ReentrantLock(); 
    // ... 
    
    public void m() { 
        lock.lock(); // block until condition holds 
        try { 
            // ... method body 
        } finally { 
            lock.unlock() 
        } 
    } 
} 

测试可重入性代码如下:

public class ReentrantLockTest {
    private final Lock lock = new ReentrantLock();
    public void reentrant() {
        try {
            // 外层加锁操作
            lock.lock();
            System.out.println(Thread.currentThread().getName() + " 外层加锁成功。");
            try {
                // 内层加锁操作
                lock.lock();
                System.out.println(Thread.currentThread().getName() + " 内层加锁成功。");
            } finally {
                // 内层解锁操作
                lock.unlock();
                System.out.println(Thread.currentThread().getName() + " 内层解锁成功。");
            }
        } finally {
            // 外层解锁操作
            lock.unlock();
            System.out.println(Thread.currentThread().getName() + " 外层解锁成功。");
        }
    }
    
    public static void main(String[] args) {
        ReentrantLockTest lockTest = new ReentrantLockTest();
        lockTest.reentrant();
    }
}

使用 tryLock() 方法实现限时等待的效果,即可以选择传入时间参数,表示等待指定的时间,无参则表示立即返回锁申请的结果:

true 表示获取锁成功,false 表示获取锁失败。

这种方法可以用来解决死锁问题。具体案例如下:

public class TryLockTest {
    private final Lock lock = new ReentrantLock();
    public void testTry() {
        boolean isFlag = false;
        try {
            // 尝试获取锁
//            isFlag = lock.tryLock();
            isFlag = lock.tryLock(5, TimeUnit.SECONDS);
            if (isFlag) {
                try {
                    TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {}
                System.out.println(Thread.currentThread().getName() + " 得到了锁,正在执行业务代码...");
            } else {
                System.out.println(Thread.currentThread().getName() + " 没有得到锁...");
            }
        }catch (Exception e){
            throw new RuntimeException(e);
        }finally {
            // 如果曾经得到了锁,那么就解锁
            if (isFlag) {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        TryLockTest demo = new TryLockTest();
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                demo.testTry();
            }, "thread-" + i).start();
        }
    }
}

当使用无参数的 tryLock() 方法时,表示尝试获取锁,如果没有获取,则立即返回,不做任何等待。那么其结果应为1个线程获取到了锁,另外4个线程立即返回。具体结果如下:

thread-0 没有得到锁...
thread-4 没有得到锁...
thread-3 没有得到锁...
thread-2 没有得到锁...
thread-1 得到了锁,正在执行业务代码...

当使用带有时间参数的 tryLock() 方法时,表示尝试获取锁,且有等待时间。那么其结果应为3个线程会获取到锁,另外2个线程因时间到没获取到锁而返回。具体结果如下:

thread-4 得到了锁,正在执行业务代码...
thread-0 得到了锁,正在执行业务代码...
thread-3 没有得到锁...
thread-2 没有得到锁...
thread-1 得到了锁,正在执行业务代码...
2. 公平锁

ReentrantLock 还可以实现公平锁。所谓公平锁,也就是在锁上等待时间最长的线程将获得锁的使用权。通俗的理解就是谁排队时间最长谁先执行获取锁。

ReentrantLock 类的构造器源码如下:

//默认为非公平锁
public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
  • public ReentrantLock():创建非公平锁
  • public ReentrantLock(boolean fair):true—创建公平锁(在锁上等待最长时间的线程有最高优先级)false—创建非公平锁

公平锁对线程操作的吞吐量有限制,效率上不如非公平锁。如果没有特殊需要还是建议使用默认的非公平锁。

具体案例如下:(结果为每3个线程为一组输出,即实现了公平锁)

public class FairLockTest {
    int ticket = 100;
    private final ReentrantLock lock = new ReentrantLock(true);
    public void sellTicket(){
        while(true){
            try{
                lock.lock();
                if(ticket > 0){
                    TimeUnit.SECONDS.sleep(1);
                    System.out.println(Thread.currentThread().getName() + " 出售了一张票,票号为:" + ticket--);
                }else{
                    break;
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally{
                lock.unlock();
            }
        }
    }
    public static void main(String[] args) {
        FairLockTest fairLockTest = new FairLockTest();
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                fairLockTest.sellTicket();
            }, "Thread-" + i).start();
        }
    }
}
3. 读写锁

在实际场景中,读操作不会改变数据,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写的操作了。

针对这种场景,Java 的并发包提供了读写锁 ReentrantReadWriteLock,它表示两个锁,一个是读操作相关的锁,称为读锁,这是一种共享锁;一个是写相关的锁,称为写锁,这是一种排他锁,也叫独占锁、互斥锁。读写锁支持非公平/公平策略。

读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞

进入读锁的条件:

  • 同一个线程内(可重入性角度):
    • 目前无锁:可以进入
    • 已经有读锁:可以进入
    • 已经有写锁:可以进入(锁可以降级,权限可以收缩)
  • 不同线程之间(互斥性角度):
    • 其他线程已经加了读锁:可以进入
    • 其他线程已经加了写锁:不能进入

进入写锁的条件:

  • 同一个线程内(可重入性角度):
    • 目前无锁:可以进入
    • 已经有读锁:不能进入(锁不能升级,权限不能扩大)
    • 已经有写锁:可以进入
  • 不同线程之间(互斥性角度):
    • 其他线程已经加了读锁:不能进入
    • 其他线程已经加了写锁:不能进入

读写锁的特点:

  • 写写不可并发
  • 读写不可并发
  • 读读可以并发
3.1 ReadWriteLock 接口

全类名:java.util.concurrent.locks.ReadWriteLock

源码如下:

public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing
     */
    Lock writeLock();
}

readLock() 方法用来获取读锁,writeLock() 方法用来获取写锁。也就是说将文件的读写操作分开,分成两种不同的锁来分配给线程,从而使得多个线程可以同时进行读操作。

该接口下我们常用的实现类是:java.util.concurrent.locks.ReentrantReadWriteLock

3.2 ReentrantReadWriteLock 类

ReentrantReadWriteLock 读写锁的类结构图如下:

在这里插入图片描述

  • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。(读锁可共享)
  • 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

测试案例如下:

public class ReadWriteLockTest {

    private final ReentrantReadWriteLock rrwl = new ReentrantReadWriteLock();
    //获得写锁
    ReentrantReadWriteLock.WriteLock writeLock = rrwl.writeLock();
    //获得读锁
    ReentrantReadWriteLock.ReadLock readLock = rrwl.readLock();

    //写操作
    public void write() {
        try {
            writeLock.lock();
            System.out.println(Thread.currentThread().getName() + " 开始写入数据...");
            TimeUnit.SECONDS.sleep(2);
            System.out.println(Thread.currentThread().getName() + " 写入数据成功!");
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            writeLock.unlock();
        }
    }

    //读操作
    public void read() {
        try {
            readLock.lock();
            System.out.println(Thread.currentThread().getName() + " 开始读取数据...");
            TimeUnit.SECONDS.sleep(1);
            System.out.println(Thread.currentThread().getName() + " 读取数据成功!");
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        ReadWriteLockTest readWriteLock = new ReadWriteLockTest();
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                readWriteLock.write();
            }, "Thread-Write" + i).start();
        }
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                readWriteLock.read();
            }, "Thread-Read-" + i).start();
        }
    }
}

以上的测试结果应为每一个写操作完成后,才会开始读操作(即对应读写不可并发)。若读和写调换位置,则结果应为先读完后再写。具体测试结果如下:

Thread-Write1 开始写入数据...
Thread-Write1 写入数据成功!
Thread-Write0 开始写入数据...
Thread-Write0 写入数据成功!
Thread-Write2 开始写入数据...
Thread-Write2 写入数据成功!
Thread-Read-0 开始读取数据...
Thread-Read-1 开始读取数据...
Thread-Read-2 开始读取数据...
Thread-Read-0 读取数据成功!
Thread-Read-2 读取数据成功!
Thread-Read-1 读取数据成功!

如若上面的测试只有读写锁中的其中一个锁(即只执行一个 for 循环),那么其结果应为,读锁为可并发,写锁为不可并发。

3.3 锁降级

在某些场景下,当线程完成了对共享资源的写操作之后,不再需要持有写锁,而需要继续使用读锁来进行后续的读操作。此时,如果该线程直接释放写锁,然后重新获取读锁,那么由于存在其他线程可能也在竞争读锁,就会导致性能的损失。针对这个问题,读写锁提供了锁降级机制。

锁降级指的是从写锁降级成为读锁。在当前线程拥有写锁的情况下,再次获取到读锁,随后释放写锁的过程就是锁降级。

锁降级的发生是在一个线程中,具体操作如下:

  • 在持有写锁的状态下,获取读锁。
  • 释放写锁,但保留读锁。
  • 在读锁的保护下访问共享资源。
  • 最后释放读锁。

锁降级实际上是指在已经获取了高级别锁的情况下,再次获取低级别锁,然后释放高级别锁的过程。在 ReentrantReadWriteLock 读写锁中,写锁是高级别锁读锁是低级别锁。锁降级可以避免死锁的发生,并且可以提高并发量和程序性能。

注意:锁支持降级,不支持升级。即一个线程获取读锁之后,不能升级为写锁。

具体案例代码如下:

public class LockDemotionTest {

    private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
    ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

    public void writeThenRead() {
        try {
            writeLock.lock();
            System.out.println(Thread.currentThread().getName() + " 正在写入数据...");
            TimeUnit.SECONDS.sleep(1);
            // 同一个线程内:在写锁尚未释放时,再加读锁
            readLock.lock();
            System.out.println(Thread.currentThread().getName() + " 正在读取数据...");
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + " 写锁释放");
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + " 读锁释放");
        }
    }

    public static void main(String[] args) {
        LockDemotionTest lockDemotion = new LockDemotionTest();
        new Thread(() -> {
            lockDemotion.writeThenRead();
        }, "锁降级线程---").start();
    }
}
4. 线程间通信
4.1 虚假唤醒

在使用 notify() 方法时,唤醒的线程会从 wait() 方法后开始执行。这时候就会出现一个问题,即如果我们加了条件判断,条件判断体中调用了 wait() 方法,那么线程被唤醒后,就会接着执行判断体中的逻辑(即不管此时是否真正满足判断,都会执行满足判断的代码逻辑)。这种情况就是虚假唤醒

官方使用文档如下:

synchronized (obj) {
    while (<condition does not hold>)
        obj.wait();
    ... // Perform action appropriate to condition
}

虚假唤醒(Spurious wakeup)是指一个线程在没有收到任何明确的通知或信号的情况下被唤醒的现象。这种情况在使用 wait()、notify() 和 notifyAll() 等相关方法时很容易发生。

假设有多个线程等待某个条件满足后继续执行,此时如果其中一个线程意外地被唤醒而不是因为满足了条件,那么它会检查条件是否满足,发现不满足就又等待,这样会浪费 CPU 资源。

虚假唤醒的原因是 Java 中使用的是信号量机制,JVM 在实现信号量时可能会出现一些错误,导致某些线程在没有被通知的情况下被唤醒。

为了避免虚假唤醒,可以在代码中使用while循环判断条件是否满足,而不是用if语句来判断。即使线程因为虚假唤醒而被唤醒,由于条件不满足,它也会再次等待。

具体案例如下:

/**
 * Description: 多个线程操作一个初始值为0的变量,实现一个线程对变量增加1,一个线程对变量减少1,交替5轮。
 */
public class FakeAwakeTest {

    private int num = 0;

    public void add() {
        synchronized (this) {
            try {
//                if (num >= 1) {
                while (num >= 1) {
                    this.wait();
                }
                //线程唤醒后加一操作
                num++;
                System.out.println(Thread.currentThread().getName() + "线程加一后的值: " + num);
                //执行加一操作后,唤醒所有减一线程
                this.notifyAll();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }

    public void sub() {
        synchronized (this) {
            try {
//                if (num < 1) {
                while (num < 1) {
                    this.wait();
                }
                //线程唤醒后加一操作
                num--;
                System.out.println(Thread.currentThread().getName() + "线程减一后的值: " + num);
                //执行减一操作后,唤醒所有加一线程
                this.notifyAll();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }

    public static void main(String[] args) {
        FakeAwakeTest fakeAwake = new FakeAwakeTest();
        for (int i = 0; i < 4; i++) {
            new Thread(() -> {
                for (int j = 0; j < 5; j++) {
                    fakeAwake.add();
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }).start();
        }
        for (int i = 0; i < 4; i++) {
            new Thread(() -> {
                for (int j = 0; j < 5; j++) {
                    fakeAwake.sub();
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }).start();
        }
    }
}

以上代码,加减一在使用 if 条件判断时,会出现 -1 等不正确的情况。换成 while 循环后,代码正常。

4.2 线程通信(Condition)

Condition 对象也是线程间通信的一种机制,是基于 Lock 锁的,它提供了与 wait()/notify() 类似的方法,但更灵活。Condition 对象通常与 Lock 对象一起使用,通过 Lock 中对的 newConition() 方法来获取一个 Condition 对象。

java.util.concurrent.locks.Condition 接口:对指定线程进行等待、唤醒操作

  • await() 方法:让线程等待
  • signal() 方法:将线程唤醒
  • signalAll()方法:唤醒全部等待中的线程

改造上面案例中的加一减一代码,测试同上,改用 Condition方式,具体代码如下:

public class ConditionTest {
    private final Lock lock = new ReentrantLock();
    //通过Lock对象创建控制线程间通信的条件对象
    Condition condition = lock.newCondition();
    
    private int num = 0;
    
    public void add() {
        lock.lock();
        try {
            while (num >= 1) {
                condition.await();
            }
            num++;
            System.out.println(Thread.currentThread().getName() + "线程加一后的值: " + num);
            condition.signalAll();
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

    public void sub() {
        lock.lock();
        try {
            while (num < 1) {
                condition.await();
            }
            num--;
            System.out.println(Thread.currentThread().getName() + "线程减一后的值: " + num);
            condition.signalAll();
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
4.3 定制化线程通信

传统的 synchronized、wait()、notifyAll() 方式无法唤醒一个指定的线程。而 Lock 配合 Condition 的方式能够唤醒指定的线程,从而执行指定线程中指定的任务。

案例如下:要求四个线程交替执行打印如下内容:

  • 线程1:打印连续数字
  • 线程2:打印连续字母
  • 线程3:打印 * 符

具体实现代码如下:

public class ConditionExercise {
    // 线程标识位,通过它区分线程切换
    private int step = 1;
    // 负责打印数字的线程要打印的数字
    private int num = 1;
    // 负责打印字母的线程要打印的字母
    private char alphaBet = 'a';
    // Lock同步锁对象
    private final Lock lock = new ReentrantLock();
    // 条件对象:对应打印数字的线程
    private Condition numCondition = lock.newCondition();
    // 条件对象:对应打印字母的线程
    private Condition alphaBetCondition = lock.newCondition();
    // 条件对象:对应打印星号的线程
    private Condition starCondition = lock.newCondition();

    // 打印数字
    public void printnum() {
        try {
            lock.lock();
            // 只要 step 对 3 取模不等于 1,就不该当前方法干活
            while (step % 3 != 1) {
                // 使用专门的条件对象,让当前线程进入等待。后面还用同一个条件对象,调用 singal() 方法就能精确的把这里等待的线程唤醒
                numCondition.await();
            }
            // 执行要打印的操作
            System.out.print(num++);
            // 精准唤醒打印字母的线程
            alphaBetCondition.signal();
            step++;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    //打印字母
    public void printAlphaBet() {
        try {
            lock.lock();
            while (step % 3 != 2) {
                alphaBetCondition.await();
            }
            System.out.print(alphaBet++);
            starCondition.signal();
            step++;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    //打印*号
    public void printStar() {
        try {
            lock.lock();
            while (step % 3 != 0) {
                starCondition.await();
            }
            System.out.println("*");
            numCondition.signal();
            step++;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ConditionExercise demo = new ConditionExercise();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                demo.printnum();
            }
        }).start();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                demo.printAlphaBet();
            }
        }).start();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                demo.printStar();
            }
        }).start();
    }
}

二、集合线程安全

Java 中的集合类大多数情况下都是非线程安全的,包括 ArrayList、HashMap、HashSet 等,这些集合类在多线程环境下使用时会出现并发问题。因为多个线程可能同时操作同一个对象,并发地修改数据,导致数据的不一致性。

然而,Java 中也有一些集合类是线程安全的,包括 Vector、Hashtable 和 ConcurrentHashMap 等。

线程安全的集合类采用了各种方法来保证并发时的线程安全,内部的数据结构使用了或者写时复制等机制,以保证多个线程并发地访问时的安全性。

而线程不安全的集合类则没有进行相应的保护机制,多个线程访问同一个集合时可能会出现以下问题:

  • 竞争条件问题:多个线程同时向集合中添加元素,可能导致元素的重复或丢失。
  • 视图读取问题:在一个线程遍历集合时,另一个线程修改了集合,导致并发修改异常。

为了避免这些并发问题,可以使用线程安全的集合类,或者在操作非线程安全的集合类时采用适当的同步机制,比如使用 Synchronized 或者 ReentrantLock 进行同步,或者使用并发容器 ConcurrentLinkedQueue、CopyOnWriteArrayList 等代替原有的集合类。

具体案例如下:

List<String> list = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            for (int j = 0; j < 3; j++) {
                // 向集合对象写入数据
                list.add(UUID.randomUUID().toString().substring(0, 5));
                // 打印集合对象,等于是读取数据
                System.out.println(list);
            }
        }).start();
}

以上代码会报以下异常:java.util.ConcurrentModificationException(并发修改异常)。原因是 ArrayList 的 add 及其他方法都是线程不安全的。

以上问题解决方法:

  • 使用 Vector 集合类型,不会抛异常,线程安全,不过这个类太古老(JDK 1.0),内存消耗过大。
  • 使用 Collections.synchronizedList(List list),不会抛异常,线程安全,不过锁定的范围大,性能低。

Collections.synchronizedList(List list) 方法的底层实现为:

private static class SynchronizedList<T> implements List<T> {
     final Object mutex;
     private final List<T> backingList;
     ...
     @Override
     public boolean add(T e) {
         synchronized(mutex) {
             return backingList.add(e);
         }
     }
}

类似的还有 Collections.synchronizedSet(Set set)、Collections.synchronizedMap(Map map),实现原理同上。

1. CopyOrWrite

写时复制(Copy-On-Write,简称 COW)是一种常见的并发编程技术。CopyOnWrite 容器,即写时复制的容器。具体的实现逻辑步骤如下:

  • 使用写时复制技术要向集合对象中写入数据时:先把整个集合数组复制一份
  • 将新数据写入复制得到的新集合数组
  • 再让指向集合数组的引用指向新复制的集合数组

优缺点:

  • 优点:写操作还是要加独占锁,这方面没区别;操作允许并发执行,效率提升。CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器
  • 缺点:由于需要把集合对象整体复制一份,所以对内存的消耗很大

从 JDK1.5 开始 Java 并发包里提供了两个使用 CopyOnWrite 机制实现的并发容器,分别为 CopyOnWriteArrayListCopyOnWriteArraySet

java.util.concurrent.CopyOnWriteArrayList 类部分源码如下:

public class CopyOnWriteArrayList<E> implements List<E>{
    final transient ReentrantLock lock = new ReentrantLock();
    private transient volatile Object[] array;
    
    ......
    
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
}    

使用 CopyOnWriteArrayList 只需将上述案例中,新建集合对象时,改为如下:其他使用与 ArrayList 一致

List list = new CopyOnWriteArrayList<>();
2. ConcurrentHashMap

ConcurrentHashMap 是 Java 中的一个并发容器,它提供了线程安全的 HashMap 实现。相比于 Hashtable 和 synchronizedMap,在并发场景下有较好的性能和吞吐量。ConcurrentHashMap 的实现原理是使用分段锁(Segment)来实现多个线程之间的独立并发访问。

具体来讲,ConcurrentHashMap 将内部的数据结构划分为一定数量的段,每个段都维护着一个大小可变的散列表。在默认情况下,ConcurrentHashMap 的段数是16,每个段中都有一个,不同的段可以由不同的线程进行访问,从而减少了线程之间的相互干扰。这个机制即为 “分段锁”,这意味着同一时刻多个线程可以并行地访问不同的段,因此在高并发的情况下,ConcurrentHashMap 的性能和吞吐量会更好。

在 Java 8 中,ConcurrentHashMap 的实现中使用了一种更加灵活的方式,分别替换为了 数组 + 链表 和 数组 + 红黑树。

使用时如下:其他使用与 HashMap 一致。

Map<String, Object> map = new ConcurrentHashMap<>();

三、常用辅助类

1. CountDownLatch

CountDownLatch(计数器闭锁)是 Java 并发包中的一个工具类,它允许一个或多个线程等待其他线程执行完某些操作后再继续执行。当线程需要等待某些条件达成后再执行任务时,可以使用 CountDownLatch 来完成。

CountDownLatch 内部维护了一个计数器,通过 countDown() 方法将计数器的值减 1,调用 await() 方法的线程会被阻塞,直到计数器的值为 0 时才会被唤醒继续执行。如果计数器的值一开始就是 0,则调用 await() 方法的线程不会被阻塞,可以直接继续执行。

效果:指定一个操作步骤数量,在各个子线程中,每完成一个任务就给步骤数量 - 1;在步骤数量减到0之前,CountDownLatch 可以帮我们把最后一步操作抑制住(阻塞),让最后一步操作一直等到步骤被减到 0 的时候执行。

构造方法:

  • public CountDownLatch (int count):构造一个用给定计数初始化的 CountDownLatch。

常用方法:

  • public void await ():当前线程调用该方法之后将会阻塞,直到计数器被减为 0。
  • public void countDown ():每调用一次,计数器减一,如果计数达到 0 则释放所有等待的线程

以集齐 7 颗龙珠为案例,具体的代码如下:

public class CountDownLatchTest {
    public static void main(String[] args) throws InterruptedException {
        // 七龙珠的数量
        int dragonBallNum = 7;
        // 创建 CountDownLatch 对象
        CountDownLatch countDownLatch = new CountDownLatch(dragonBallNum);
        System.out.println("---开始收集龙珠---");
        for (int i = 0; i < 7; i++) {
            int num = i;
            new Thread(() -> {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("已经收集达到 " + num + " 号龙珠");
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();
        System.out.println("---龙珠集齐,召唤神龙---");
    }
}
2. CyclicBarrier

CyclicBarrier(循环栅栏)是 Java 并发包中的一个工具类,它可以使一组线程互相等待,直到达到某个公共的屏障点后再一起继续执行。

不同于 CountDownLatch,CyclicBarrier 可以在多个线程之间形成一个同步点,让这些线程在这个同步点处等待,而不是让一个线程去等待其他线程。

效果:多线程在执行各自任务的时候,到达某个状态点就等待,等所有线程都到达这个状态点再继续执行后步骤。

CyclicBarrier 内部维护着一个计数器和一个屏障点状态。每当一个线程到达屏障点时,它会调用 await() 方法等待其他线程到达,直到所有等待的线程都到达屏障点后,它们才会继续执行。而且,CyclicBarrier 可以循环使用,当所有等待的线程都被释放后,CyclicBarrier 重新回到初始化状态,可以被再次使用。

构造方法:

  • public CyclicBarrier (int parties, Runnable barrierAction):创建一个 CyclicBarrier 实例,parties 指定参与相互等待的线程数,barrierAction 一个可选的 Runnable 命令,该命令只在每个屏障点运行一次,可以在执行后续业务之前共享状态。该操作由最后一个进入屏障点的线程执行。
  • public CyclicBarrier (int parties):创建一个 CyclicBarrier 实例,parties 指定参与相互等待的线程数。

常用方法:

  • public int await ():该方法被调用时表示当前线程已经到达屏障点,当前线程阻塞进入休眠状态,直到所有线程都到达屏障点,当前线程才会被唤醒。

以过关为例,三个人都通过才算通关,具体代码如下:

public class CyclicBarrierTest {
    public static void main(String[] args) {
        // 创建 CyclicBarrier 实例
        CyclicBarrier barrier = new CyclicBarrier(3, () -> {
            System.out.println(Thread.currentThread().getName() + "过关了!");
        });

        for (int i = 1; i <= 3; i++) {
            new Thread(() -> {
                try {
                    TimeUnit.SECONDS.sleep(4);
                    System.out.println(Thread.currentThread().getName() + "通过第 1 关");
                    // 当所有人通过第一关才允许进入下一关
                    barrier.await();

                    TimeUnit.SECONDS.sleep(4);
                    System.out.println(Thread.currentThread().getName() + "通过第 2 关");
                    barrier.await();

                    TimeUnit.SECONDS.sleep(4);
                    System.out.println(Thread.currentThread().getName() + "通过第 3 关");
                    barrier.await();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }, String.valueOf(i)).start();
        }
    }
}
3. Semaphore

Semaphore(信号量)是 Java 并发包中的一个工具类,用于管理一组资源的访问。Semaphore 主要用于控制同时访问某个特定资源的线程数量,也可以用于实现整体流量控制或者控制某一资源池的访问数量。

Semaphore 内部维护了一个指定数量的许可证(permit),acquire() 方法尝试获取一个许可证,如果没有许可证可用就会阻塞等待直到有许可证可用;release() 方法释放一个许可证,使得其他等待许可的线程可以获取到许可继续执行。

使用 Semaphore 可以帮助我们管理资源位;当某个线程申请资源时,由 Semaphore 检查这个资源是否可用;如果其他线程释放了这个资源,那么申请资源的线程就可以使用。

Semaphore 可以初始化一个许可数量,当许可数量已经被占用时,尝试再次获取许可的线程会被阻塞在该 Semaphore 上,直到有一个许可被释放。因此,Semaphore 可以用作简单的线程池控制器。

构造方法:

  • public Semaphore (int permits):初始化一个 Semaphore 对象,并指定许可证的数量为 permits。

常用方法:

  • public void acquire():占用资源,当一个线程调用 acquire() 方法时,它要么通过成功获取信号量(信号量减1),要么一直等下去,直到有线程释放信号量,或超时。
  • public void release():(释放)实际上会将信号量的值加 1,然后唤醒等待的线程。
  • public boolean tryAcquire(long timeout, TimeUtil unit):尝试占用资源,若超过指定时间,则立即返回,不会一直等待。

以停车位为案例,具体代码如下:

public class SemaphoreTest {
    public static void main(String[] args) {
        // 创建 Semaphore 对象,指定资源数量为 3
        Semaphore semaphore = new Semaphore(3);
        // 创建 10 个线程争夺这 3 个资源
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    // 申请资源
                    semaphore.acquire();
                    // 拿到资源执行操作
                    System.out.println("【" + Thread.currentThread().getName() + "】号车辆【驶入】车位");
                    TimeUnit.SECONDS.sleep(3);
                    System.out.println("【" + Thread.currentThread().getName() + "】号车辆【驶出】车位");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 操作完成释放资源
                    semaphore.release();
                }
            }, i + "").start();
        }
    }
}

四、阻塞队列

阻塞队列(Blocking Queue)是一种特殊的队列,阻塞队列是线程池的核心组件

BlockingQueue 即阻塞队列,是 java.util.concurrent 下的一个接口,BlockingQueue 是为了解决多线程中数据高效安全传输而提出的。

在多线程领域:所谓阻塞,在某些情况下会挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动被唤起

从阻塞这个词可以看出,在某些情况下对阻塞队列的访问可能会造成阻塞。被阻塞的情况主要有如下两种:

  • 当队列满了的时候进行入队列操作
  • 当队列空了的时候进行出队列操作

因此,当一个线程试图对一个已经满了的队列进行入队列操作时,它将会被阻塞,除非有另一个线程做了出队列操作;同样,当一个线程试图对一个空队列进行出队列操作时,它将会被阻塞,除非有另一个线程进行了入队列操作。

阻塞队列主要用在生产者-消费者的场景,其中生产者线程将数据放入队列中,而消费者线程从队列中取出数据。如下图所示:

在这里插入图片描述

阻塞队列可以有效地解耦生产者线程和消费者线程之间的控制,从而简化了线程同步的实现。

生产者-消费者模型是多线程编程中的常见场景,例如数据缓存、网络数据传输等。

为什么需要BlockingQueue?好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切 BlockingQueue 都给你一手包办了。在concurrent 包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。

BlockingQueue 接口

java.util.concurrent 包里的 BlockingQueue 是一个接口,继承 Queue 接口,Queue 接口继承 Collection 接口(与 List 等集合一样)。

BlockingQueue 接口主要有以下 7 个实现类:

  • ArrayBlockingQueue:由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:由链表结构组成的有界(但大小默认值为 Integer.MAX_VALUE )阻塞队列。
  • PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
  • DelayQueue:使用优先级队列实现的延迟无界阻塞队列。
  • SynchronousQueue:不存储元素的阻塞队列,也即单个元素的队列。
  • LinkedTransferQueue:由链表组成的无界阻塞队列。
  • LinkedBlockingDeque:由链表组成的双向阻塞队列。

BlockingQueue 接口官方 API 中这样划分它的方法:

抛出异常特定值阻塞超时
插入add(e)offer(e)put(e)offer(e, time, unit)
移除remove()poll()take()poll(time, unit)
检查element()peek()不可用不可用

详细说明如下:

抛出异常:

  • add(e):正常执行返回 true;当阻塞队列满时,再往队列里 add 插入元素会抛 IllegalStateException:Queue full
  • element():正常执行返回阻塞队列中的第一个元素;当阻塞队列空时,再调用 element() 检查元素会抛出 NoSuchElementException
  • remove():正常执行返回阻塞队列中的第一个元素并删除这个元素;当阻塞队列空时,再往队列里 remove() 移除元素会抛 NoSuchElementException

特定值:

  • offer(e):成功返回 true,失败返回 false
  • poll():队列中有元素时,返回移除的元素;队列中无元素时,返回 null
  • peek():队列中有元素时,返回队列中的的第一个元素;队列中无元素时,返回 null

一直阻塞:

  • put(e):队列未满时,添加成功;队列已满时,线程阻塞等待,直到能够添加为止
  • take():队列不为空时,获取队列中的第一个元素;队列为空时,线程阻塞等待,直到队列为非空时获取第一个元素

超时退出:

  • offer(e, time, unit):如果试图执行的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。 返回 true 或 false 以告知该操作是否成功
  • poll(time, unit):如果试图执行的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。

五、线程池

线程池(Thread Pool)是一种实现线程复用的机制,它包含若干预先创建的线程,并可以将任务提交给这些线程执行。线程池可以简化线程的创建和销毁过程,有效减少线程创建和销毁带来的开销,提高了程序的性能和稳定性。

1. 概念与架构

线程池的优势:线程池做的工作主要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量达到了最大数量,超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。

它的主要特点为:线程复用;控制最大并发数;管理线程。

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的销耗。
  • 提高响应速度。当任务到达时,任务可以不需要等待线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会销耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 Executor,ExecutorService,ThreadPoolExecutor 这几个类。具体关系图如下:

在这里插入图片描述

2. 创建线程池

在 JDK 原生 API 中可以使用 Executors 工具类创建线程池对象。

  • newCachedThreadPool():执行很多短期异步任务,线程池根据需要创建多线程。并在先前创建的线程可用时将重用它们。可扩容,遇强则强。
  • newFixedThreadPool(int nThreads):执行长期任务性能好,创建一个线程池,一池有 N 个固定的线程。
  • newSingleThreadExecutor():一个任务一个任务的执行,一池一线程。

使用案例如下:

public class ThreadPoolDemo {
    public static void main(String[] args) {
        // 创建线程池
        ExecutorService executor = Executors.newFixedThreadPool(3);
        // ExecutorService executor = Executors.newCachedThreadPool();
        // ExecutorService executor = Executors.newSingleThreadExecutor();
        try {
            for (int i = 0; i < 5; i++) {
                executor.execute(() -> {
                    System.out.println(Thread.currentThread().getName() + "执行了业务逻辑");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executor.shutdown();
        }
    }
}

上述案例中的三个方法的本质都是 ThreadPoolExecutor 的实例化对象,只是具体参数值不同。它们底层源码如下:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

通过下面的核心参数介绍,我们可以得知上面三个方法的源码具体含义:

  • newCachedThreadPool():核心线程数为 0,最大线程数为 Integer.MAX_VALUE(2^32 - 1),线程空闲后的存活时间为 60 s,使用的阻塞队列是单个元素的队列,线程工厂以及拒绝策略都为默认的。
  • newFixedThreadPool(int nThreads):核心线程和最大线程数都为 nThreads,线程空闲后的存活时间为 0(即在大于核心线程数时,空闲即销毁),使用的阻塞队列是链表结构,线程工厂以及拒绝策略都为默认的。
  • newSingleThreadExecutor():核心线程数和最大线程数都为 1,线程存活时间为 0,使用的阻塞队列是链表结构,线程工厂以及拒绝策略都为默认的。

使用 Executors 工具类创建的线程池参数设置不太不合理,实际开发时通常需要自己创建 ThreadPoolExecutor 的对象,自己指定参数。

3. 7个核心参数

ThreadPoolExecutor 类的构造器有四个,其中三个都是调用这个 7 个参数的构造器。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize:线程池的核心线程数量,即线程池中可以保持活动状态的最小线程数。当提交的任务数超过核心线程数量时,线程池可以创建更多的线程来执行任务。
  • maximumPoolSize:线程池中允许同时存在的最大线程数量,即当任务数超过核心线程数量时,线程池可以创建的最大线程数量。当线程池中的线程数量已经达到最大值且任务队列已满时,可以采取拒绝策略处理过多的任务。可以在创建线程池时指定最大线程数,也可以在运行时动态地修改。
  • keepAliveTime:线程空闲后的存活时间。当线程池中的线程数量超过核心线程数时,空闲的线程会在等待新任务到来的过程中等待一段时间,如果等待时间超过了 keepAliveTime,则该线程会被销毁,以释放资源。
  • unit:keepAliveTime 的单位,可以是毫秒、微秒、纳秒等等。
  • workQueue:线程池中用于保存等待中任务的阻塞队列。
  • threadFactory:线程工厂,用于创建线程的工厂,可以自定义实现。
  • handler:当线程池已经关闭或已满时,新任务的处理策略。默认情况下,线程池采取的处理方式是 AbortPolicy,即抛出异常;还有其他的处理策略,包括CallerRunsPolicy(调用者处理)、DiscardOldestPolicy(抛弃最老的任务)和DiscardPolicy(抛弃最新的任务)。可以通过实现RejectedExecutionHandler 接口自定义处理方式。
4. 底层工作原理

线程池的工作流程如下图所示:

在这里插入图片描述

  • 在创建了线程池后,线程池中的线程数为零

  • 当调用 execute() 方法添加一个请求任务时,线程池会做出如下判断:

    • 如果正在运行的线程数量 小于 corePoolSize,那么马上创建线程运行这个任务;
    • 如果正在运行的线程数量 大于或等于 corePoolSize,那么将这个任务放入队列
    • 如果这个时候队列满了且正在运行的线程数量还 小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
    • 如果队列满了且正在运行的线程数量 大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
  • 当一个线程完成任务时,它会从队列中取下一个任务来执行。

  • 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:

    • 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
    • 所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小
5. 拒绝策略

一般我们创建线程池时,为防止资源被耗尽,任务队列都会选择创建有界任务队列,但这种模式下如果出现任务队列已满且线程池创建的线程数达到你设置的最大线程数时,这时就需要你指定 ThreadPoolExecutor 的 RejectedExecutionHandler 参数即合理的拒绝策略,来处理线程池"超载"的情况。

ThreadPoolExecutor 自带的拒绝策略如下:

  • AbortPolicy(默认):直接抛出 RejectedExecutionException 异常阻止系统正常运行
  • CallerRunsPolicy:“调用者运行”一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
  • DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加人队列中 尝试再次提交当前任务。
  • DiscardPolicy:该策略默默地丢弃无法处理的任务,不予任何处理也不抛出异常。 如果允许任务丢失,这是最好的一种策略。

以上内置的策略均实现了 RejectedExecutionHandler 接口,也可以自己扩展 RejectedExecutionHandler 接口,定义自己的拒绝策略

六、高并发底层原理

1. JMM

JMM(Java Memory Model)是 Java 内存模型的缩写,它定义了 Java 程序中线程之间如何通过内存进行通信的规范。JMM 规定了对于不同线程之间共享的变量的访问方式、顺序和可见性等行为。

JMM 就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM 是从 Java 5 开始的。

在这里插入图片描述

  • 主内存(Main Memory):主内存是所有线程共享的内存区域,包含了所有的变量。
  • 工作内存(Working Memory):也叫本地内存,工作内存是每个线程独有的内存区域,用于存储主内存中被该线程使用到的变量的副本。工作内存负责与主内存和线程交互。
  • 共享变量(Shared Variable):如果一个变量被多个线程使用,那么这个变量会在每个线程的工作内存中保有一个副本,这种变量就是共享变量。

从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存

JMM对共享内存的操作做出了如下两条规定:

  • 线程对共享内存的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写;
  • 不同线程无法直接访问其他线程工作内存中的变量,因此共享变量的值传递需要通过主内存完成。

内存模型的三大特性:

  • 原子性:即不可分割性。比如:
    • a=0(a 非 long 和 double 类型) 这个操作是不可分割的,那么我们说这个操作是原子操作。
    • a++; 这个操作实际是 a = a + 1;是可分割的,所以他不是一个原子操作。

非原子操作都会存在线程安全问题,需要使用同步技术(sychronized)或者锁(Lock)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。Java 的 concurrent 包下提供了一些原子类,比如:AtomicInteger、AtomicLong、AtomicReference等。

  • 可见性:每个线程都有自己的工作内存,所以当某个线程修改完某个变量之后,在其他的线程中,未必能观察到该变量已经被修改。**在 Java 中 volatile、synchronized 和 final 实现可见性。**volatile 只能让被他修饰内容具有可见性,但不能保证它具有原子性。
  • 有序性:Java 的有序性跟线程相关。一个线程内部所有操作都是有序的,如果是多个线程所有操作都是无序的。因为 JMM 的工作内存和主内存之间存在延迟,而且 Java 会对一些指令进行重新排序。volatile 和 synchronized 可以保证程序的有序性,也能保证指令不进行重排序。
    • volatile 关键字本身就包含了禁止指令重排序的语义
    • synchronized 则是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入
  • 指令重排是编译器和处理器为了提高程序执行效率而进行的一种优化技术。在指令重排中,编译器和处理器可能会重新排序原始指令的执行顺序,以充分利用处理器的特定优化机制。
  • 指令重排对于单线程程序是透明的,不会影响程序的最终结果。然而,在多线程环境下,指令重排 可能 会导致程序的行为出现问题。这是因为指令重排会导致不同线程间的操作顺序发生变化,从而违反了代码中所期望的顺序关系和时序约束。

对于“可能”的理解是:线程 A 的指令执行顺序在线程 B 看来是没有保证的。如果运气好的话,线程 B 也许真的可以看到和线程 A 一样的执行顺序。

2. volatile关键字

volatile 关键字的主要作用是确保被修饰的变量在多线程环境下具有 可见性有序性。但它并不能保证变量的原子性。可见性尤为重要。

volatile 确实是一个为数不多的能够从编码层面影响指令重排序的关键字。因为它可以在底层指令中添加内存屏障

所谓内存屏障,就是一种特殊的指令。底层指令中加入内存屏障,会禁止一定范围内指令的重排。

volatile 关键字只能修饰成员变量

volatile 变量是一种稍弱的同步机制,用来确保将变量的更新操作通知到其他线程。

在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。

  • volatile + CAS = 原子类的底层原理
  • volatile + CAS + 线程对象的双向链表 = AQS 的底层原理
  • AQS 是 JUC 中各种 API 底层用到的同步器的实现原理

验证可见性的代码如下:

@Data
public class VolatileTest {
    private volatile int data = 100;
    public static void main(String[] args) {
        VolatileTest demo = new VolatileTest();
        new Thread(()->{
            while (demo.getData() == 100) {}
            System.out.println("AAA 线程发现 data 新值:" + demo.getData());
        }, "AAA").start();
        new Thread(()->{
            try {
                TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) {}
            demo.setData(200);
            System.out.println("BBB 线程修改 data,新值是:" + demo.getData());
        }, "BBB").start();
    }
}

结果为,先打印 BBB 线程,后打印 AAA 线程。说明 AAA 线程能够获取到 BBB 线程修改后的 data 值。如果去掉 volatile 关键字,则 AAA 线程进入死循环。

volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的变量值 flush 到主内存。

volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

所以 volatile 关键字是能够保证可见性的。

Happen-Before 原则:先行发生原则,意思就是当 A 操作先行发生于 B 操作,则在发生 B 操作的时候,操作 A 产生的影响能被 B 观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

3. CAS

CAS(Compare and Swap),比较并交换的意思。

CAS 操作有 3 个基本参数:内存地址 A,旧值 B,新值 C。它的作用是将指定内存地址 A 的内容与所给的旧值 B 相比,

  • 如果相等,则将其内容替换为指令中提供的新值 C;
  • 如果不等,则更新失败。

类似于修改登陆密码的过程。当用户输入的原密码和数据库中存储的原密码相同,才可以将原密码更新为新密码,否则就不能更新。

**CAS 是解决多线程并发安全问题的一种乐观锁算法。**因为它在对共享变量更新之前,会先比较当前值是否与更新前的值一致,如果一致则更新,如果不一致则循环执行(称为自旋锁),直到当前值与更新前的值一致为止,才执行更新。

Unsafe 类是 CAS 的核心类,提供硬件级别的原子操作(目前所有 CPU 基本都支持硬件级别的 CAS 操作)。

// 对象、对象的属性地址偏移量、预期值、修改值
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

java.util.concurrent.atomic 包下有很多原子操作的包装类,例如:AtomicInteger、AtomicLong 等。

AtomicInteger 等原子类可以看成是 CAS 机制配合 volatile 实现非阻塞同步的经典案例——程序运行的效果和加了同步锁一样,但是底层并没有阻塞线程。

AtomicInteger 类的部分源码如下:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final Unsafe unsafe = Unsafe.getUnsafe();

    private volatile int value;

    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
}

测试代码如下:

public class AtomicIntegerTest {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(5);
        
        // 基于旧值 5 修改
        // updateResult = true 当前值 = 666
        boolean updateResult = atomicInteger.compareAndSet(5, 666);
        System.out.println("updateResult = " + updateResult + " 当前值 = " + atomicInteger.get());

        // 基于旧值 5 修改
        // updateResult = false 当前值 = 666
        updateResult = atomicInteger.compareAndSet(5, 777);
        System.out.println("updateResult = " + updateResult + " 当前值 = " + atomicInteger.get());

        // 基于旧值 666 修改
        // updateResult = true 当前值 = 888
        updateResult = atomicInteger.compareAndSet(666, 888);
        System.out.println("updateResult = " + updateResult + " 当前值 = " + atomicInteger.get());
    }
}

CAS 机制可以看做是乐观锁理念的一种具体实现。但是又不完整。因为乐观锁的具体实现通常是需要维护版本号的,但是 CAS 机制中并不包含版本号——如果有版本号辅助就不会有 ABA 问题了。

CAS 的缺点:

  • 开销大:在并发量比较高的情况下,如果反复尝试更新某个变量,却又一直更新不成功,会给 CPU 带来较大的压力
  • ABA问题:当变量从 A 修改为 B 再修改回 A 时,变量值等于期望值 A,但是无法判断是否修改,CAS 操作在 ABA 修改后依然成功。
  • 不能保证代码块的原子性:CAS 机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。

单纯使用使用原子类执行并发操作时,能够保证线程安全,而且性能很好。但是使用场景有很大的局限性。例如:AtomicInteger 仅仅用于对 Integer 范围的整数类型的并发操作。

4. AQS

AQS(AbstractQueuedSynchronizer),抽象队列同步器。java.util.concurrent.locks 包下的一个类。

AQS 定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它。

它维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里 volatile 是核心关键词。state 的访问方式有三种:getState()、getState()、getState()。

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行)、Share(共享,多个线程可同时执行)。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  • boolean isHeldExclusively():该线程是否正在独占资源。只有用到 condition 才需要去重写它。
  • boolean tryAcquire(int):尝试以独占方式获取资源。成功则返回true,失败则返回false。int 类型的参数是用来累加 state 的
  • boolean tryRelease(int):尝试以独占方式释放资源。成功则返回true,失败则返回false。int 类型的参数是用来从 state 中减去的
  • int tryAcquireShared(int):尝试以共享方式获取资源。返回:负数表示获取失败,正数表示获取成功且有剩余资源,0表示获取成功但没有剩余资源
  • boolean tryReleaseShared(int):尝试以共享方式释放资源。如果释放后允许唤醒后续等待结点返回 true,否则返回 false。

以 ReetrantLock 为例,说明AQS在锁底层的应用。

在 ReentrantLock 类中包含了 3 个 AQS 的实现类:

  • 抽象类 Sync
  • 非公平锁实现类 NonfaireSync
  • 公平锁实现类 FairSync

ReentrantLock 部分源码如下:

abstract static class Sync extends AbstractQueuedSynchronizer {...}
    
static final class NonfairSync extends Sync {...}
         
static final class FairSync extends Sync {...}
  1. Sync 抽象类内部方法主要包括:
/**
 * 自定义方法:为非公平锁的实现提供快捷路径
 */
abstract void lock();

/**
 * 自定义通用方法,两个子类的tryAcquire()方法都需要使用非公平的trylock方法
 */
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) { // 如果当前没有线程获取到锁
        if (compareAndSetState(0, acquires)) { // 则CAS获取锁
            setExclusiveOwnerThread(current); // 如果获取锁成功,把当前线程设置为有锁线程
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) { // 如果当前线程已经拥有锁,则重入
        int nextc = c + acquires; // 每重入一次stat累加acquires
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

/**
 * 实现AQS的释放锁方法
 */
protected final boolean tryRelease(int releases) {
    int c = getState() - releases; // 每释放一次stat就减releases
    if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不是有锁线程抛异常
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) { // stat减为0则释放锁
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

protected final boolean isHeldExclusively() {
    // While we must in general read state before owner,
    // we don't need to do so to check if current thread is owner
    return getExclusiveOwnerThread() == Thread.currentThread();
}
  1. NonfaireSync 类内部方法主要包括:
static final class NonfairSync extends Sync {
  
    final void lock() {
        if (compareAndSetState(0, 1)) // CAS把stat设置为1
            setExclusiveOwnerThread(Thread.currentThread()); // 获取到锁
        else
            acquire(1); // acquire(1)方法是AQS自己实现的本质就是调用tryAcquire方法,如果tryAcquire获取到锁并无法进入等待队列则中止线程。
    }

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires); // 使用了Sync抽象类的nonfairTryAcquire方法
    }
}

acquire(1)方法是AQS自己实现的本质就是调用tryAcquire方法,如果tryAcquire获取到锁并无法进入等待队列则中止线程。

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
    ...
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    ...
}
  1. FaireSync 类内部方法主要包括:
static final class FairSync extends Sync {

    final void lock() {
        acquire(1);
    }
    
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedPredecessors() &&  // 从线程有序等待队列中获取等待
                compareAndSetState(0, acquires)) { 
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) { // 可重入
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

hasQueuedPredecessors 具体实现逻辑如下:

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
    ...
    public final boolean hasQueuedPredecessors() {
        // The correctness of this depends on head being initialized
        // before tail and on head.next being accurate if the current
        // thread is first in queue.
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }
    ...
}
  • 当等待队列只有一个线程时,直接获取到锁
  • 如果队列不止一个线程,并且下一个线程就是当前申请锁的线程,则获取锁
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我真真的是小白

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值