多线程是Java最基本的一种并发模型

多线程基础

进程

1、在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。
2、某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程
3、进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
4、操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。
5、实现多任务的方法,有以下几种:

  • 多进程模式(每个进程只有一个线程):

image.png

  • 多线程模式(一个进程有多个线程):

image.png

  • 多进程+多线程模式(复杂度最高):

image.png

进程vs线程

1、进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。
2、进程和线程的特点
和多线程相比,多进程的缺点在于:

  • 创建进程比创建线程开销大,尤其是在Windows系统上;
  • 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。

多进程的优点在于:
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。

多线程

1、Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。
2、和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步
3、Java多线程编程的特点又在于:

  • 多线程模型是Java程序最基本的并发模型;
  • 后续读写网络、数据库、Web开发等都依赖Java多线程模型。


创建新线程

1、要创建一个新线程非常容易,我们需要实例化一个Thread实例,然后调用它的start()方法。望新线程能执行指定的代码,有以下几种方法:
方法一:从Thread派生一个自定义类,然后覆写run()方法:

  1. public class Main {
  2. public static void main(String[] args) {
  3. Thread t = new MyThread();
  4. t.start(); // 启动新线程
  5. }
  6. }
  7. class MyThread extends Thread {
  8. @Override
  9. public void run() {
  10. System.out.println("start new thread!");
  11. }
  12. }

注意:start()方法会在内部自动调用实例的run()方法。
方法二:创建Thread实例时,传入一个Runnable实例:

  1. public class Main {
  2. public static void main(String[] args) {
  3. Thread t = new Thread(new MyRunnable());
  4. t.start(); // 启动新线程
  5. }
  6. }
  7. class MyRunnable implements Runnable {
  8. @Override
  9. public void run() {
  10. System.out.println("start new thread!");
  11. }
  12. }

方法三:用Java8引入的lambda语法进一步简写

  1. public class Main {
  2. public static void main(String[] args) {
  3. Thread t = new Thread(() -> {
  4. System.out.println("start new thread!");
  5. });
  6. t.start(); // 启动新线程
  7. }
  8. }

2、要模拟并发执行的效果,我们可以在线程中调用Thread.sleep()强迫当前线程暂停一段时间sleep()传入的参数是毫秒。

  1. public class Main {
  2. public static void main(String[] args) {
  3. System.out.println("main start...");
  4. Thread t = new Thread() {
  5. public void run() {
  6. System.out.println("thread run...");
  7. try {
  8. Thread.sleep(10);
  9. } catch (InterruptedException e) {}
  10. System.out.println("thread end.");
  11. }
  12. };
  13. t.start();
  14. try {
  15. Thread.sleep(20);
  16. } catch (InterruptedException e) {}
  17. System.out.println("main end...");
  18. }
  19. }

3、线程的优先级
可以对线程设定优先级,设定优先级的方法是:

  1. Thread.setPriority(int n) // 1~10, 默认值5

优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。

线程的状态

1、在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

用一个状态转移图表示如下:
image.png
2、线程终止的原因有:

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)。

3、一个线程还可以等待另一个线程直到其运行结束。例如,main线程在启动t线程后,可以通过t.join()等待t线程结束后再继续运行:

  1. public class ThreadJoin {
  2. public static void main(String[] args) throws InterruptedException {
  3. Thread t = new Thread(() -> {
  4. System.out.println("hello");
  5. });
  6. System.out.println("start");
  7. t.start();
  8. //t.join()等待t线程结束后再继续运行
  9. t.join();
  10. System.out.println("end");
  11. }
  12. }
  13. start
  14. hello
  15. end

如果t线程已经结束,对实例t调用join()会立刻返回。此外,join(long)的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。

中断线程

1、如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()方法,使得自身线程能立刻结束运行。
例如:假设从网络下载一个100M的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。
2、中断一个线程非常简单,只需要在其他线程中对目标线程调用**interrupt()**方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。

  1. public class ThreadInterrupt {
  2. public static void main(String[] args) throws InterruptedException {
  3. Thread t = new MyThread1();
  4. t.start();
  5. Thread.sleep(1); //暂停一毫秒
  6. t.interrupt(); //中断t线程
  7. t.join(); //等待t线程结束
  8. System.out.println("end");
  9. }
  10. }
  11. class MyThread1 extends Thread {
  12. @Override
  13. public void run() {
  14. int n = 0;
  15. while (!isInterrupted()) {
  16. n++;
  17. System.out.println(n + " hello!");
  18. }
  19. }
  20. }

如果线程处于等待状态,例如,t.join()会让main线程进入等待状态,此时,如果对main线程调用interrupt()join()方法会立刻抛出InterruptedException,因此,目标线程只要捕获到join()方法抛出的InterruptedException,就说明有其他线程对其调用了interrupt()方法,通常情况下该线程应该立刻结束运行。

  1. public class InterruptedExceptionDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. Thread t = new MyThread2();
  4. t.start();
  5. Thread.sleep(1000);
  6. t.interrupt(); // 中断t线程
  7. t.join(); // 等待t线程结束
  8. System.out.println("end");
  9. }
  10. }
  11. class MyThread2 extends Thread {
  12. public void run() {
  13. Thread hello = new HelloThread();
  14. hello.start(); // 启动hello线程
  15. try {
  16. hello.join(); // 等待hello线程结束
  17. } catch (InterruptedException e) {
  18. System.out.println("interrupted!");
  19. }
  20. hello.interrupt();
  21. }
  22. }
  23. class HelloThread extends Thread {
  24. public void run() {
  25. int n = 0;
  26. while (!isInterrupted()) {
  27. n++;
  28. System.out.println(n + " hello!");
  29. try {
  30. Thread.sleep(100);
  31. } catch (InterruptedException e) {
  32. break;
  33. }
  34. }
  35. }
  36. }

main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。在t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出。
3、另一个常用的中断线程的方法是设置标志位。我们通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束。

  1. public class InterruptRunning {
  2. public static void main(String[] args) throws InterruptedException {
  3. HelloThread2 t = new HelloThread2();
  4. t.start();
  5. Thread.sleep(1);
  6. t.running = false;// 标志位置为false
  7. }
  8. }
  9. class HelloThread2 extends Thread {
  10. //volatile是线程间共享的变量,确保每个线程都能读取到更新后的变量值
  11. public volatile boolean running = true;
  12. public void run() {
  13. int n = 0;
  14. while (running) {
  15. n++;
  16. System.out.println(n + "hello!");
  17. }
  18. System.out.println("end!");
  19. }
  20. }

为什么要对线程间共享的变量用关键字volatile声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。
因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,确保其他线程能够立刻看到修改后的值。

守护线程

1、守护线程的背景

  • Java程序入口是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。
  • 如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
  • 但是有一种线程的目的就是无限循环,问题是,由谁负责结束这个线程?

2、守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。因此,JVM退出时,不必关心守护线程是否已结束。
3、创建守护线程
方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

  1. Thread t = new MyThread();
  2. t.setDaemon(true);
  3. t.start();

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

线程同步

1、当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。如果多个线程同时读写共享变量,会出现数据不一致的问题,**需要通过synchronized同步。**同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码。
2、如果多个线程同时读写共享变量,会出现数据不一致的问题。来看一个例子:

  1. public class SynchronizedThreadDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. Thread add=new AddThread();
  4. Thread dec=new DecThread();
  5. add.start();
  6. dec.start();
  7. add.join();
  8. dec.join();
  9. System.out.println(Counter.count);
  10. }
  11. }
  12. class Counter {
  13. public static int count = 0;
  14. }
  15. class AddThread extends Thread {
  16. public void run() {
  17. for (int i = 0; i < 1000; i++) {
  18. Counter.count += 1;
  19. }
  20. }
  21. }
  22. class DecThread extends Thread {
  23. public void run() {
  24. for (int i = 0; i < 1000; i++) {
  25. Counter.count -= 1;
  26. }
  27. }
  28. }

两个线程同时对一个int变量进行操作,一个加1000次,一个减1000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
3、多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待。
image.png
假设n的值是100,如果两个线程同时执行n = n + 1,通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
4、保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized关键字对一个对象进行加锁,synchronized保证了代码块在任意时刻最多只有一个线程能执行。
1)找出修改共享变量的线程代码块;
2)选择一个共享实例作为锁;
3)使用synchronized(lockObject) { ... }

  1. synchronized(lock) {
  2. n = n + 1;
  3. }

5、使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。

  1. public class SynchronizedThreadDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. Thread add = new AddThread();
  4. Thread dec = new DecThread();
  5. add.start();
  6. dec.start();
  7. add.join();
  8. dec.join();
  9. System.out.println(Counter.count);
  10. }
  11. }
  12. class Counter {
  13. public static final Object lock = new Object();
  14. public static int count = 0;
  15. }
  16. class AddThread extends Thread {
  17. public void run() {
  18. for (int i = 0; i < 1000; i++) {
  19. synchronized (Counter.lock) { //获取锁
  20. Counter.count += 1;
  21. } //释放锁
  22. }
  23. }
  24. }
  25. class DecThread extends Thread {
  26. public void run() {
  27. for (int i = 0; i < 1000; i++) {
  28. synchronized (Counter.lock) {
  29. Counter.count -= 1;
  30. }
  31. }
  32. }
  33. }

6、JVM规范定义了几种原子操作:

  • 基本类型赋值,例如:int n = m
  • 引用类型赋值,例如:List<String> list = anotherList

单条原子操作的语句不需要同步,如果是多行赋值语句,就必须保证是同步操作。

同步方法

1、Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要。让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized逻辑封装起来。

public class Counter1 {
    private int count = 0;

    public void add(int n) {
        synchronized (this) {
            count += n;
        }
    }

    //    public void dec(int n) {
//        synchronized (this) {
//            count -= n;
//        }
//    }

    //用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁
    public synchronized void dec(int n) {
        count -= n;
    }

    public int get() {
        return count;
    }

}

2、如果一个类被设计为允许多线程正确访问,就说这个类就是“线程安全”的(thread-safe)。Java标准库的java.lang.StringBuffer也是线程安全的。还有一些不变类,例如StringIntegerLocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的。

没有特殊说明时,一个类默认是非线程安全的。

3、对于static方法,是没有this实例的,因为static方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的Class实例。

死锁

1、Java的线程锁是可重入的锁。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。
2、死锁

public void add(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value += m;
        synchronized(lockB) { // 获得lockB的锁
            this.another += m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}

public void dec(int m) {
    synchronized(lockB) { // 获得lockB的锁
        this.another -= m;
        synchronized(lockA) { // 获得lockA的锁
            this.value -= m;
        } // 释放lockA的锁
    } // 释放lockB的锁
}

在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()dec()方法时:

  • 线程1:进入add(),获得lockA
  • 线程2:进入dec(),获得lockB

随后:

  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
3、如何避免死锁?
线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序。

使用wait和notify

1、在Java程序中,synchronized解决了多线程竞争的问题。但是synchronized并没有解决多线程协调的问题。多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
2、调用wait()方法后,线程进入等待状态,wait()方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()方法才会返回,然后,继续执行下一条语句。wait()方法调用时,会释放线程获得的锁,wait()方法返回后,线程又会重新试图获得锁。
3、如何让等待的线程被重新唤醒,然后从wait()方法返回?答案是在相同的锁对象上调用notify()方法。使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()方法内部的wait()中等待,使用notifyAll()将一次性全部唤醒。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。

public class TaskQueueDemo {
    public static void main(String[] args) {
        TaskQueue q = new TaskQueue();
        List<Thread> ts = new ArrayList<Thread>();
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread() {
                public void run() {
                    //执行task:
                    while (true) {
                        try {
                            String s = q.getTask();
                            System.out.println("execute task: " + s);
                        } catch (InterruptedException e) {
                            return;
                        }
                    }
                }
            };
            t.start();
            ts.add(t);
        }
    }
}

//TaskQueue.java
public class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    //往队列中添加任务
    public synchronized void addTask(String s) {
        this.queue.add(s);
        this.notifyAll();//唤醒在this锁等待的线程
    }

    //取出队列的第一个任务
    public synchronized String getTask() throws InterruptedException {
        //如果队列为空,就循环等待,直到另一个线程往队列中放入了一个任务
        while (queue.isEmpty()) {
            // 释放this锁:
            this.wait();
            // 重新获取this锁
        }
        return queue.remove();
    }
}

使用ReentrantLock

1、Java语言直接提供了synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁。
2、synchronized是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock是Java代码实现的锁,我们就必须先获取锁,然后在finally中正确释放锁。

public class CounterRelock {
    private final Lock lock = new ReentrantLock();
    private int count;

    public void add(int n) {
        lock.lock();
        try {
            count += n;
        } finally {
            lock.unlock();
        }
    }
}

3、ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。和synchronized不同的是,ReentrantLock可以尝试获取锁:

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        ...
    } finally {
        lock.unlock();
    }
}

上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。
所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。

使用Condition

1、背景

  • 使用ReentrantLock比直接使用synchronized更安全,可以替代synchronized进行线程同步。
  • 但是,synchronized可以配合waitnotify实现线程在条件不满足时等待,条件满足时唤醒,用ReentrantLock我们怎么编写waitnotify的功能呢?
  • 答案是使用Condition对象来实现waitnotify的功能。

2、使用Condition时,引用的Condition对象必须从Lock实例的newCondition()返回,这样才能获得一个绑定了Lock实例的Condition实例。

public class TaskQueueRelock {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}

Condition提供的await()signal()signalAll()原理和synchronized锁对象的wait()notify()notifyAll()是一致的,并且其行为也是一样的。
3、和tryLock()类似,await()可以在等待指定时间后,如果还没有被其他线程通过signal()signalAll()唤醒,可以自己醒来。

if (condition.await(1, TimeUnit.SECOND)) {
    // 被其他线程唤醒
} else {
    // 指定时间内没有被其他线程唤醒
}

使用ReadWriteLock

1、ReentrantLock保证了只有一个线程可以执行临界区代码,但是有些时候,这种保护有点过头。实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。
2、使用ReadWriteLock可以解决这个问题,它保证:

  • 只允许一个线程写入(其他线程既不能写入也不能读取);
  • 没有写入时,多个线程允许同时读(提高性能)。

3、把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock

public class CounterReWrLock {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); //加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); //释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); //加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); //释放读锁
        }
    }
}

使用StampedLock

1、ReadWriteLock可以解决多线程同时读,但只有一个线程能写的问题。它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
2、StampedLockReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。

public class Point_StampedLock {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

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

    public double distanceFromOrigin() {
        //获得一个乐观读锁,返回版本号
        long stamp = stampedLock.tryOptimisticRead();
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        double currentX = x;
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentY = y;
        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        if (!stampedLock.validate(stamp)) {// 检查乐观读锁后是否有其他写锁发生
            //在读取过程中有写入,会验证失败时,通过悲观读锁再次读取
            stamp = stampedLock.readLock(); //获取一个悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); //释放悲观读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
可见,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是**StampedLock**是不可重入锁,不能在一个线程中反复获取同一个锁。
StampedLock还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。
**

使用Concurrent集合

1、BlockingQueue的意思就是说,当一个线程调用这个TaskQueuegetTask()方法时,该方法内部可能会让线程变成等待状态,直到队列条件满足不为空,线程被唤醒后,getTask()方法才会返回。可以直接使用Java标准库的java.util.concurrent包提供的线程安全的集合:ArrayBlockingQueue。除了BlockingQueue外,针对ListMapSetDeque等,java.util.concurrent包也提供了对应的并发集合类。

interface non-thread-safe thread-safe
List ArrayList CopyOnWriteArrayList
Map HashMap ConcurrentHashMap
Set HashSet / TreeSet CopyOnWriteArraySet
Queue ArrayDeque / LinkedList ArrayBlockingQueue / LinkedBlockingQueue
Deque ArrayDeque / LinkedList LinkedBlockingDeque

2、使用这些并发集合与使用非线程安全的集合类完全相同。因为所有的同步和加锁的逻辑都在集合内部实现,对外部调用者来说,只需要正常按接口引用,其他代码和原来的非线程安全代码完全一样。即当我们需要多线程访问时,可以把:

Map<String, String> map = new HashMap<>();

改为:

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

使用Atomic

1、Java的java.util.concurrent包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic包。
2、以AtomicInteger为例,它提供的主要操作有:

  • 增加值并返回新值:int addAndGet(int delta)
  • 加1后返回新值:int incrementAndGet()
  • 获取当前值:int get()
  • 用CAS方式设置:int compareAndSet(int expect, int update)

3、Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。