• 20200225 Java 多线程(1)-廖雪峰


    Java 多线程(1)-廖雪峰

    多线程基础

    进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

    操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。

    Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等

    因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。

    和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。

    Java多线程编程的特点又在于:

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

    创建新线程

    我们希望新线程能执行指定的代码,有以下几种方法:

    • 方法一:从Thread派生一个自定义类,然后覆写run()方法
    • 方法二:创建Thread实例时,传入一个Runnable实例

    直接调用run()方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。

    必须调用Thread实例的start()方法才能启动新线程,如果我们查看Thread类的源代码,会看到start()方法内部调用了一个private native void start0()方法,native修饰符表示这个方法是由JVM虚拟机内部的C代码实现的,不是由Java代码实现的。

    线程的优先级

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

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

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

    小结

    Java用Thread对象表示一个线程,通过调用start()启动一个新线程;

    一个线程对象只能调用一次start()方法;

    线程的执行代码写在run()方法中;

    线程调度由操作系统决定,程序本身无法决定调度顺序;

    Thread.sleep()可以把当前线程暂停一段时间。

    线程的状态

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

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

    用一个状态转移图表示如下:

             ┌─────────────┐
             │     New     │
             └─────────────┘
                    │
                    ▼
    ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
     ┌─────────────┐ ┌─────────────┐
    ││  Runnable   │ │   Blocked   ││
     └─────────────┘ └─────────────┘
    │┌─────────────┐ ┌─────────────┐│
     │   Waiting   │ │Timed Waiting│
    │└─────────────┘ └─────────────┘│
     ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                    │
                    ▼
             ┌─────────────┐
             │ Terminated  │
             └─────────────┘
    

    当线程启动后,它可以在RunnableBlockedWaitingTimed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。

    线程终止的原因有:

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

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

    public class Main {
        public static void main(String[] args) throws InterruptedException {
            Thread t = new Thread(() -> {
                System.out.println("hello");
            });
            System.out.println("start");
            t.start();
            t.join();
            System.out.println("end");
        }
    }
    

    main线程对线程对象t调用join()方法时,主线程将等待变量t表示的线程运行结束,即join就是指等待该线程结束,然后才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是main线程先打印startt线程再打印hellomain线程最后再打印end

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

    小结

    Java线程对象Thread的状态包括:NewRunnableBlockedWaitingTimed WaitingTerminated

    通过对另一个线程对象调用join()方法可以等待其执行结束;

    可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;

    对已经运行结束的线程调用join()方法会立刻返回。

    中断线程

    如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()方法,使得自身线程能立刻结束运行。

    我们举个栗子:假设从网络下载一个100M的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。

    中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。

    @Slf4j
    public class Main {
        public static void main(String[] args) throws InterruptedException {
            Thread t = new MyThread();
            t.start();
            Thread.sleep(10); // 暂停1毫秒
            t.interrupt(); // 中断t线程
            t.join(); // 等待t线程结束
            log.info("end");
        }
    }
    
    @Slf4j
    class MyThread extends Thread {
        public void run() {
            int n = 0;
            while (!isInterrupted()) {
                n++;
                log.info(n + " hello!");
            }
        }
    }
    

    仔细看上述代码,main线程通过调用t.interrupt()方法中断t线程,但是要注意,interrupt()方法仅仅向t线程发出了“中断请求”,至于t线程是否能立刻响应,要看具体代码。而t线程的while循环会检测isInterrupted(),所以上述代码能正确响应interrupt()请求,使得自身立刻结束运行run()方法。

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

    我们来看下面的示例代码:

    @Slf4j
    public class Main {
        public static void main(String[] args) throws InterruptedException {
            Thread t = new MyThread();
            t.start();
            Thread.sleep(1000);
            t.interrupt(); // 中断t线程
            t.join(); // 等待t线程结束
            log.info("end");
        }
    }
    
    @Slf4j
    class MyThread extends Thread {
        public void run() {
            Thread hello = new HelloThread();
            hello.start(); // 启动hello线程
            try {
                hello.join(); // 等待hello线程结束
            } catch (InterruptedException e) {
                log.info("MyThread interrupted!");
            }
            hello.interrupt();
        }
    }
    
    @Slf4j
    class HelloThread extends Thread {
        public void run() {
            int n = 0;
            while (!isInterrupted()) {
                n++;
                log.info(n + " hello!");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    log.info("HelloThread interrupted!");
                    break;
                }
            }
        }
    }
    

    main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。在t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出。

    另一个常用的中断线程的方法是设置标志位。我们通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束:

    public class Main {
        public static void main(String[] args) throws InterruptedException {
            HelloThread t = new HelloThread();
            t.start();
            Thread.sleep(1);
            t.running = false; // 标志位置为false
        }
    }
    
    class HelloThread extends Thread {
        public volatile boolean running = true;
    
        public void run() {
            int n = 0;
            while (running) {
                n++;
                System.out.println(n + " hello!");
            }
            System.out.println("end!");
        }
    }
    

    注意到HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。

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

    ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
               Main Memory
    │                               │
       ┌───────┐┌───────┐┌───────┐
    │  │ var A ││ var B ││ var C │  │
       └───────┘└───────┘└───────┘
    │     │ ▲               │ ▲     │
     ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─
          │ │               │ │
    ┌ ─ ─ ┼ ┼ ─ ─ ┐   ┌ ─ ─ ┼ ┼ ─ ─ ┐
          ▼ │               ▼ │
    │  ┌───────┐  │   │  ┌───────┐  │
       │ var A │         │ var C │
    │  └───────┘  │   │  └───────┘  │
       Thread 1          Thread 2
    └ ─ ─ ─ ─ ─ ─ ┘   └ ─ ─ ─ ─ ─ ─ ┘
    

    这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。

    因此,volatile关键字的目的是告诉虚拟机:

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

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

    如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。

    小结

    对目标线程调用interrupt()方法可以请求中断一个线程,目标线程通过检测isInterrupted()标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException

    目标线程检测到isInterrupted()true或者捕获了InterruptedException都应该立刻结束自身线程;

    通过标志位判断需要正确使用volatile关键字;

    volatile关键字解决了共享变量在线程间的可见性问题。

    守护线程

    如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。

    然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?

    答案是使用守护线程(Daemon Thread)。

    守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。

    因此,JVM退出时,不必关心守护线程是否已结束。

    如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

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

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

    小结

    守护线程是为其他线程服务的线程;

    所有非守护线程都执行完毕后,虚拟机退出;

    守护线程不能持有需要关闭的资源(如打开文件等)。

    线程同步

    当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。

    这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。

    我们来看一个例子:

    public class Main {
        public static void main(String[] args) throws Exception {
            AddThread add = new AddThread();
            DecThread dec = new DecThread();
            add.start();
            dec.start();
            add.join();
            dec.join();
            System.out.println(Counter.count);
        }
    }
    
    class Counter {
        public static int count = 0;
    }
    
    class AddThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                Counter.count += 1;
            }
        }
    }
    
    class DecThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                Counter.count -= 1;
            }
        }
    }
    

    上面的代码很简单,两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。

    这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。

    例如,对于语句:

    n = n + 1;
    

    看上去是一行语句,实际上对应了3条指令:

    ILOAD
    IADD
    ISTORE
    

    我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于:

    ┌───────┐    ┌───────┐
    │Thread1│    │Thread2│
    └───┬───┘    └───┬───┘
        │            │
        │ILOAD (100) │
        │            │ILOAD (100)
        │            │IADD
        │            │ISTORE (101)
        │IADD        │
        │ISTORE (101)│
        ▼            ▼
    

    如果线程1在执行ILOAD后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD后获取的值仍然是100,最终结果被两个线程的ISTORE写入后变成了101,而不是期待的102

    这说明多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待:

    ┌───────┐     ┌───────┐
    │Thread1│     │Thread2│
    └───┬───┘     └───┬───┘
        │             │
        │-- lock --   │
        │ILOAD (100)  │
        │IADD         │
        │ISTORE (101) │
        │-- unlock -- │
        │             │-- lock --
        │             │ILOAD (101)
        │             │IADD
        │             │ISTORE (102)
        │             │-- unlock --
        ▼             ▼
    

    通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

    可见,保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized关键字对一个对象进行加锁:

    synchronized(lock) {
        n = n + 1;
    }
    

    synchronized保证了代码块在任意时刻最多只有一个线程能执行。我们把上面的代码用synchronized改写如下:

    public class Main {
        public static void main(String[] args) throws Exception {
            AddThread add = new AddThread();
            DecThread dec = new DecThread();
            add.start();
            dec.start();
            add.join();
            dec.join();
            System.out.println(Counter.count);
        }
    }
    
    class Counter {
        public static final Object lock = new Object();
        public static int count = 0;
    }
    
    class AddThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                synchronized (Counter.lock) {
                    Counter.count += 1;
                }
            }
        }
    }
    
    class DecThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                synchronized (Counter.lock) {
                    Counter.count -= 1;
                }
            }
        }
    }
    

    注意到代码:

    synchronized(Counter.lock) { // 获取锁
        ...
    } // 释放锁
    

    它表示用Counter.lock实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { ... }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是0。

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

    我们来概括一下如何使用synchronized

    1. 找出修改共享变量的线程代码块;
    2. 选择一个共享实例作为锁;
    3. 使用synchronized(lockObject) { ... }

    在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁:

    public void add(int m) {
        synchronized (obj) {
            if (m < 0) {
                throw new RuntimeException();
            }
            this.value += m;
        } // 无论有无异常,都会在此释放锁
    }
    

    我们再来看一个错误使用synchronized的例子:

    public class Main {
        public static void main(String[] args) throws Exception {
            AddThread add = new AddThread();
            DecThread dec = new DecThread();
            add.start();
            dec.start();
            add.join();
            dec.join();
            System.out.println(Counter.count);
        }
    }
    
    class Counter {
        public static final Object lock1 = new Object();
        public static final Object lock2 = new Object();
        public static int count = 0;
    }
    
    class AddThread extends Thread {
        public void run() {
            for (int i=0; i<10000; i++) {
                synchronized(Counter.lock1) {
                    Counter.count += 1;
                }
            }
        }
    }
    
    class DecThread extends Thread {
        public void run() {
            for (int i=0; i<10000; i++) {
                synchronized(Counter.lock2) {
                    Counter.count -= 1;
                }
            }
        }
    }
    

    结果并不是0,这是因为两个线程各自的synchronized锁住的不是同一个对象!这使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。

    因此,使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。

    我们再看一个例子:

    public class Main {
        public static void main(String[] args) throws Exception {
            Thread[] ts = new Thread[]{new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread()};
            for (Thread t : ts) {
                t.start();
            }
            for (Thread t : ts) {
                t.join();
            }
            System.out.println(Counter.studentCount);
            System.out.println(Counter.teacherCount);
        }
    }
    
    class Counter {
        public static final Object lock = new Object();
        public static int studentCount = 0;
        public static int teacherCount = 0;
    }
    
    class AddStudentThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                synchronized (Counter.lock) {
                    Counter.studentCount += 1;
                }
            }
        }
    }
    
    class DecStudentThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                synchronized (Counter.lock) {
                    Counter.studentCount -= 1;
                }
            }
        }
    }
    
    class AddTeacherThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                synchronized (Counter.lock) {
                    Counter.teacherCount += 1;
                }
            }
        }
    }
    
    class DecTeacherThread extends Thread {
        public void run() {
            for (int i = 0; i < 10000; i++) {
                synchronized (Counter.lock) {
                    Counter.teacherCount -= 1;
                }
            }
        }
    }
    

    上述代码的4个线程对两个共享变量分别进行读写操作,但是使用的锁都是Counter.lock这一个对象,这就造成了原本可以并发执行的Counter.studentCount += 1Counter.teacherCount += 1,现在无法并发执行了,执行效率大大降低。实际上,需要同步的线程可以分成两组:AddStudentThreadDecStudentThreadAddTeacherThreadDecTeacherThread,组之间不存在竞争,因此,应该使用两个不同的锁,即:

    AddStudentThreadDecStudentThread使用lockStudent锁:

    synchronized(Counter.lockStudent) {
        ...
    }
    

    AddTeacherThreadDecTeacherThread使用lockTeacher锁:

    synchronized(Counter.lockTeacher) {
        ...
    }
    

    这样才能最大化地提高执行效率。

    不需要synchronized的操作

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

    • 基本类型(longdouble除外)赋值,例如:int n = m
    • 引用类型赋值,例如:List list = anotherList

    longdouble是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把longdouble的赋值作为原子操作实现的。

    单条原子操作的语句不需要同步。例如:

    public void set(int m) {
        synchronized(lock) {
            this.value = m;
        }
    }
    

    就不需要同步。

    对引用也是类似。例如:

    public void set(String s) {
        this.value = s;
    }
    

    上述赋值语句并不需要同步。

    但是,如果是多行赋值语句,就必须保证是同步操作,例如:

    class Pair {
        int first;
        int last;
        public void set(int first, int last) {
            synchronized(this) {
                this.first = first;
                this.last = last;
            }
        }
    }
    

    有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:

    class Pair {
        int[] pair;
        public void set(int first, int last) {
            int[] ps = new int[] { first, last };
            this.pair = ps;
        }
    }
    

    就不再需要同步,因为this.pair = ps是引用赋值的原子操作。而语句:

    int[] ps = new int[] { first, last };
    

    这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

    小结

    多线程同时读写共享变量时,会造成逻辑错误,因此需要通过synchronized同步;

    同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;

    注意加锁对象必须是同一个实例;

    对JVM定义的单个原子操作不需要同步。

    同步方法

    如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter类就是线程安全的。Java标准库的java.lang.StringBuffer也是线程安全的。

    还有一些不变类,例如StringIntegerLocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。

    最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。

    除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的。

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

    下面两种写法是等价的:

    public void add(int n) {
        synchronized(this) { // 锁住this
            count += n;
        } // 解锁
    }
    
    public synchronized void add(int n) { // 锁住this
        count += n;
    } // 解锁
    

    因此,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁。

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

    public class Counter {
        public synchronized static void test(int n) {
            ...
        }
    }
    
    public class Counter {
        public static void test(int n) {
            synchronized(Counter.class) {
                ...
            }
        }
    }
    

    考察Counterget()方法:

    public class Counter {
        private int count;
    
        public int get() {
            return count;
        }
        ...
    }
    

    它没有同步,因为读一个int变量不需要同步。

    然而,如果我们把代码稍微改一下,返回一个包含两个int的对象:

    public class Counter {
        private int first;
        private int last;
    
        public Pair get() {
            Pair p = new Pair();
            p.first = first;
            p.last = last;
            return p;
        }
        ...
    }
    

    就必须要同步了。

    小结

    synchronized修饰方法可以把整个方法变为同步代码块,synchronized方法加锁对象是this

    通过合理的设计和数据封装可以让一个类变为“线程安全”;

    一个类没有特殊说明,默认不是thread-safe;

    多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析。

    死锁

    Java的线程锁是可重入的锁。

    什么是可重入的锁?我们还是来看例子:

    public class Counter {
        private int count = 0;
    
        public synchronized void add(int n) {
            if (n < 0) {
                dec(-n);
            } else {
                count += n;
            }
        }
    
        public synchronized void dec(int n) {
            count += n;
        }
    }
    

    观察synchronized修饰的add()方法,一旦线程执行到add()方法内部,说明它已经获取了当前实例的this锁。如果传入的n < 0,将在add()方法内部调用dec()方法。由于dec()方法也需要获取this锁,现在问题来了:

    对同一个线程,能否在获取到锁以后继续获取同一个锁?

    答案是肯定的。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。

    由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

    死锁

    一个线程可以获取一个锁后,再继续获取另一个锁。例如:

    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进程。

    因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。

    那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:

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

    小结

    Java的synchronized锁是可重入锁;

    死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待;

    避免死锁的方法是多线程获取锁的顺序要一致。

    参考

  • 相关阅读:
    Codeforces Round #169 (Div. 2) B. Little Girl and Game(博弈)
    Codeforces Round #167 (Div. 2) C. Dima and Staircase(线段树·成段更新,繁琐)
    Codeforces Round #170 (Div. 2) B. New Problem(好题)
    BKDR Hash Function
    DOC常用命令(转)
    C++ GUI Qt4 自学笔记
    windows如何取消开机启动项
    如何查看电脑配置
    Codeforces Round #166 (Div. 2)C. Secret(构造)
    Codeforces Round #168 (Div. 2) C. kMultiple Free Set(二分查找)
  • 原文地址:https://www.cnblogs.com/huangwenjie/p/12361473.html
Copyright © 2020-2023  润新知