Java的基本使用之多线程

1、多线程的基本介绍

现代操作系统(Windows,macOS,Linux)都可以执行多任务,多任务就是同时运行多个任务。

现在,多核CPU已经非常普及了,但是,即使过去的单核CPU,也可以执行多任务。由于CPU执行代码都是顺序执行的,操作系统轮流让各个任务交替执行,任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒……这样反复执行下去。表面上看,每个任务都是交替执行的,但是,由于CPU的执行速度实在是太快了,我们感觉就像所有任务都在同时执行一样。

真正的并行执行多任务只能在多核CPU上实现,但是,由于任务数量远远多于CPU的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行。

1.1、进程和线程的概念

在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。某些进程内部还需要同时执行多个子任务,我们把子任务称为线程。由于每个进程至少要干一件事,所以,一个进程至少有一个线程。

进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。 操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。

 

1.2、实现多任务的方式(多进程、多线程、多进程+多线程)

因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:

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

 

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

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

 

1.2.1、多进程和多线程的对比

进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。

具体采用哪种方式,要考虑到进程和线程的特点。

和多线程相比,多进程的缺点在于:

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

而多进程的优点在于:

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

 

1.3、Java程序中的多线程

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

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

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

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

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

掌握Java多线程编程是非常必要的

 

2、创建新线程

Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。

要创建一个新线程非常容易,我们需要先实例化一个Thread实例,然后调用它的start()方法,一个线程对象只能调用一次start()方法。

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread();
        t.start(); // 启动新线程
    }
}

上面那个线程启动后实际上什么也不做就立刻结束了。如果我们希望新线程能执行指定的代码,有以下几种方法:

一、从Thread派生一个自定义类,然后覆写run()方法:

public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start(); // 启动新线程,调用实例的run()方法
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

也可以像下面一样创建一个Thread的匿名子类:

Thread thread = new Thread(){
   public void run(){
     System.out.println("Thread Running");
   }
};
thread.start();

 

二、创建Thread实例时,传入一个Runnable实例:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable());
        t.start(); // 启动新线程

        Thread t2 = new Thread(new MyRunnable(), "t2Name");  //在创建线程时,可以给线程起名
        t2.start(); // 启动新线程
    }
}
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());  //输出线程名
        System.out.println("start new thread!");
    }
}

//用Java8引入的lambda语法可以简写为:
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("start new thread!");
        });
        t.start(); // 启动新线程
    }
}

一般我们使用传入Runnable实例的方式来创建线程。这种方式可以避免Java单继承的局限性,因为第一种方式继承了Thread类就无法继承其他类。并且多个线程可以共享同一个接口实现类的对象,非常适合多个相同线程来处理同一份资源。比如下面,t 和 t2 线程将会共享count变量,count变量将会因为另一线程的操作而发生改变。

public class Main {
    public static void main(String[] args) {
        MyRunnable run = new MyRunnable();

        Thread t = new Thread(run);   //传入同一个Runnable实例,多个线程共享同一份资源
        Thread t2 = new Thread(run, "t2Name");
        t.start();    
        t2.start(); 
    }
}
class MyRunnable implements Runnable {
    int count = 0;

    @Override
    public void run() {
        for(int i=0; i<5; i++){
            count++;
        }
    }
}    

 

要特别注意:直接调用Thread实例的run()方法是无效的。直接调用run()方法,只是相当于 main() 方法里调用了一个普通的Java方法,不会启动新线程,当前线程也不会发生任何改变。必须调用Thread实例的start()方法才能启动新线程,如果我们查看Thread类的源代码,会看到start()方法内部调用了一个private native void start0()方法,native修饰符表示这个方法是由JVM虚拟机内部的C代码实现的,不是由Java代码实现的。

 

2.1、线程的执行顺序

如下代码:

我们用蓝色表示主线程,也就是main线程,,红色代表新线程。main线程执行的代码有4行,首先打印main start,然后创建Thread对象,紧接着调用start()启动新线程。当start()方法被调用时,JVM就创建了一个新线程,我们通过实例变量t来表示这个新线程对象,并开始执行。接着,main线程继续执行打印main end语句,而t线程在main线程执行的同时会并发执行,打印thread runthread end语句。

run()方法结束时,新线程就结束了。而main()方法结束时,主线程也结束了。

我们再来看线程的执行顺序:

  1. main线程肯定是先打印main start,再打印main end
  2. t线程肯定是先打印thread run,再打印thread end

但是,除了可以肯定,main start会先打印外,main end打印在thread run之前、thread end之后或者之间,都无法确定。因为从t线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序。

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

 

2.2、设置线程的优先级

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

设定优先级的方法:

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

可以通过 threadObj.getPriority() 方法来获取某个线程的优先级

threadInstance.getPriority()   //返回一个整型数据

 

3、线程的6种状态及切换(New、Runnable、Blocked、Waiting、Timed Waiting、Terminated)

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

  • New(初始):新创建了一个线程对象,但还没有调用start()方法。
  • Runnable(运行):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,具备了运行的条件,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
  • Blocked(阻塞):表示线程阻塞于锁。
  • Waiting(等待):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
  • Timed Waiting(超时等待):该状态不同于WAITING,它可以在指定的时间后自行返回。
  • Terminated(终止):表示该线程已经执行完毕。

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

线程终止的原因有:

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

用一个图来表示线程状态的转移如下:

1)初始状态(New)

实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。

2)就绪状态(ready)

  1. 就绪状态只是说你有资格运行,调度程序没有挑选到你,你就永远是就绪状态。
  2. 调用线程的start()方法,此线程进入就绪状态。
  3. 当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
  4. 当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
  5. 锁池里的线程拿到对象锁后,进入就绪状态。

2.2)运行中状态(running)

线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。

3)阻塞状态(Blocked)

阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。

4)等待(Waiting)

处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

5)超时等待(Timed Waiting)

处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

6)终止状态(Terminated)

  1. 当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
  2. 在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

 

3.1、线程跟状态相关的几种方法(sleep()、yield()、join()、wait()、notify())

线程跟状态相关的几种方法如下:

1)Thread.sleep(long millis):当前线程调用此方法,进入TIMED_WAITING状态,但不释放对象锁,millis毫秒过后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。

2)Thread.yield():当前线程调用此方法,放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与sleep()类似,只是不能由用户指定暂停多长时间。

3)t.join()/t.join(long millis):当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程进入就绪状态。

4)obj.wait():当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。

5)obj.notify():唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。

6)obj.stop():强制结束某一线程的生命周期,不管它有没有执行完毕

7)obj.isAlive():判断某一线程是否仍然存活,true表示仍然存活,false表示已经终止。线程一旦终止,就不能复生。

 

3.2、线程的 join() 方法

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

//下面代码将依次输出:start  hello  end
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();  //这里调用t.join()方法,main线程将会等待t线程运行结束后才会继续执行下面的代码
        System.out.println("end");
    }
}

通过对另一个线程对象调用join()方法可以等待其执行结束。上面当main线程对线程对象t调用join()方法时,主线程将等待变量t表示的线程运行结束。

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

 3.3、线程让步(yield()方法)

调用 Thread.yield() 方法线程会由运行态到就绪态,停止一下后再由就绪态到运行态。调用 yield 方法会让当前线程交出CPU权限,让CPU去执行其他的线程,但是它只会把执行机会让给优先级相同或者更高的线程。注意调用 yield 方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取 CPU 执行时间,这一点是和sleep方法不一样的。

public class TestThread16 {
    public static void main(String[] args) {
        MyThreadTest mt = new MyThreadTest();
        new Thread(mt,"1").start();
        new Thread(mt,"2").start();
        new Thread(mt,"3").start();
    }
}

class MyThreadTest implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            Thread.yield();
            System.out.println("当前线程:" + Thread.currentThread().getName() + ",i =" + i);
        }
    }
}

 

4、中断线程

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

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

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

示例代码:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(1); // 暂停1毫秒
        t.interrupt(); // 中断t线程
        t.join(); // 等待t线程结束
        System.out.println("end");
    }
}

class MyThread extends Thread {
    public void run() {
        int n = 0;
        while (! isInterrupted()) {   //必须得一直监听是否是interrupted状态才能中断线程
            n ++;
            System.out.println(n + " hello!");
        }
    }
}

通过调用t.interrupt()方法中断线程只是向 t 线程发出了“中断请求”,至于 t 线程是否能立刻响应,还要看具体代码。

 

如果某一线程比如 A 线程处于等待状态(比如 A 线程调用了 join() 方法在等待其他线程的执行完毕,此时 A 线程就是处于等待状态),此时其他线程对 A 调用 interrupt() 方法企图中断 A 线程,那么 A 线程中的 join() 方法就会立刻抛出InterruptedException异常。因此,如果某一线程比如 A 线程只要捕获到本身线程内的 join() 方法抛出了InterruptedException异常,就说明有其他线程对它调用了interrupt()方法,通常情况下 A 线程应该立刻结束运行,并且最好也中断其调线程内开启的其他线程,否则其他线程仍然会继续执行,且 JVM 不会退出。

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

class MyThread extends Thread {
    public void run() {
        Thread hello = new HelloThread();
        hello.start(); // 启动hello线程
        try {
            hello.join(); // 等待hello线程结束
        } catch (InterruptedException e) {
            System.out.println("interrupted!");
        }
        hello.interrupt();
    }
}

class HelloThread extends Thread {
    public void run() {
        int n = 0;
        while (!isInterrupted()) {
            n++;
            System.out.println(n + " hello!");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

 

另一个常用的中断线程的方法是设置标志位。我们通常会用一个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;  //HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。
    public void run() {
        int n = 0;
        while (running) {
            n ++;
            System.out.println(n + " hello!");
        }
        System.out.println("end!");
    }
}

 

4.1、volatile关键字

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

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

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

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

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

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

 

5、守护线程

Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。

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

但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:

class TimerThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println(LocalTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,此时可以使用守护线程。守护线程是指为其他线程服务的线程。

在 Java 中,JVM 虚拟机在所有非守护线程都执行完毕后会自动退出,不会关心守护线程是否已结束。但如果非守护线程一直没有执行完毕,那么 JVM 进程也会一直在运行不会退出。

5.1、创建守护线程

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

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

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

 

6、线程同步

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

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

public class Main {
    public static void main(String[] args) throws Exception {
        var add = new AddThread();
        var 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; }
    }
}

上面代码理论上最后输出 Counter.count 应该为 0,因为不管执行顺序先后,最后都是对 Counter.count 进行了加减 10000。但实际上可以看到代码执行每次输出的结果都不一定。

原因解析:

对变量进行读取和写入时,结果要正确,必须保证对变量的操作是原子操作(原子操作是指不能被中断的一个或一系列操作),即不能被中断,并且在对变量操作过程中其他线程不能同时对变量进行操作。

例如,对于语句:n=n+1;  看上去是一行语句,实际上对应了3条指令:ILOAD  IADD  ISTORE 

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

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

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

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

 

6.1、通过synchronized关键字实现加解锁

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

synchronized(lockObj) {   //获取锁
    n = n + 1;
}  //释放锁

synchronized保证了代码块在任意时刻最多只有一个线程能执行,使用synchronized的步骤:

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

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

我们把之前的代码用synchronized改写如下:

public class Main {
    public static void main(String[] args) throws Exception {
        var add = new AddThread();
        var 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.lock共享实例作为锁
                Counter.count += 1;
            }  //释放锁
        }
    }
}
class DecThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {
            synchronized(Counter.lock) {
                Counter.count -= 1;
            }
        }
    }
}

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

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

 

请注意:线程的原子操作要想不被其他线程中断,那么各自线程 synchronized 锁住的 lock 对象必须得是同一个实例对象。如果两个线程各自的synchronized锁住的不是同一个对象,那么这两个线程各自都可以同时获得锁,那么线程之间的原子操作仍然可以并发进行,导致数据同时发生修改而出错。

JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。

如果线程之间的原子操作可以并发执行,那么就可以不使用同一个锁对象。比如A和B线程需要同步,C和D线程需要同步,那么A和B需要用同一个lock对象,C和D需要用同一个lock对象,但这两组的lock对象不必是同一个,不然会大大降低执行效率。

 

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

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

 

6.2、JVM规定的几种原子操作

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

  • 基本类型(longdouble除外)赋值,例如:int n = m。(longdouble是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把longdouble的赋值作为原子操作实现的)
  • 引用类型赋值,例如:List<String> list = anotherList

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

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;
        }
    }
}

当然,我们也可以将多条赋值语句写成一条赋值语句,这样就可以不必要写同步操作了。

每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

 

7、同步方法(synchronized修饰)

7.1、线程安全的类

让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized逻辑封装起来。例如,我们编写一个计数器如下:

public class Counter {
    private int count = 0;

    public void add(int n) {
        synchronized(this) {  //用synchronized锁住当前实例,实现同步
            count += n;
        }
    }
    public void dec(int n) {
        synchronized(this) { //用synchronized锁住当前实例,实现同步
            count -= n;
        }
    }

    public int get() {   //读一个int变量不需要同步,但是如果读的是多个变量时就需要同步
        return count;
    }
}

这样一来,线程调用add()dec()方法时,它不必关心同步逻辑,因为synchronized代码块在add()dec()方法内部。并且,我们注意到,synchronized锁住的对象是this,即当前实例,这又使得创建多个Counter实例的时候,它们之间互不影响,可以并发执行:

var c1 = Counter();
var c2 = Counter();

// 对c1进行操作的线程:
new Thread(() -> {
    c1.add();
}).start();
new Thread(() -> {
    c1.dec();
}).start();

// 对c2进行操作的线程:
new Thread(() -> {
    c2.add();
}).start();
new Thread(() -> {
    c2.dec();
}).start();

现在,对于Counter类,多线程可以正确调用。

如果一个类被设计为允许多线程正确访问,我们就说这个类就是线程安全(thread-safe)的,上面的Counter类就是线程安全的。

Java标准库的java.lang.StringBuffer也是线程安全的。还有一些不变类,例如StringIntegerLocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。但是大部分类(例如ArrayList,没有特殊说明的类一般都不是线程安全的都是非线程安全的类,我们不能在多线程中修改它们。但如果所有线程都只对这些类进行读取,不写入,那么这些类也是可以安全地在线程间共享的。

7.2、类的同步方法(synchronized)

在线程安全的类当中,我们锁住的是this实例时,像这种方法我们可以改为用ynchronized

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

//上面的方法等价于:
public synchronized void add(int n) { // 锁住this
    count += n;
} // 解锁

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

静态方法的synchronized修饰符锁住的是该类的Class实例。对于静态方法,是没有this实例的,因为static方法是针对类而不是实例。但是任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的Class实例。

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

//上面的方法相当于:
public class Counter {
    public static void test(int n) {
        synchronized(Counter.class) {
            ...
        }
    }
}

 

8、死锁

8.1、可重入锁

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

下面的代码中,synchronized 修饰 add 方法,一旦线程执行到 add 方法内部,说明它已经获得了当前实例的 this 锁,如果传入的n < 0,将在add()方法内部调用dec()方法,dec()方法也需要获取this锁,并且 dec() 方法也能获取到 this 锁。在 Java 中,同一个线程在获取到锁仍可以继续获取同一个锁。

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

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;
    }
}

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

 

8.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进程。因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待;

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

比如上面的代码,应该严格按照先获取lockA,再获取lockB的顺序,可以将dec()方法改写如下:

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

 

9、等待和唤醒线程(wait、notify、notifyAll)

9.1、将线程挂起(wait())

wait() 方法可将当前线程挂起并放弃CPU、同步资源,使别的线程可访问并修改共享资源,而当前线程则需等待被唤醒才有资格再次运行。wait()方法必须在当前获取的锁对象上调用。

在多线程进行协调运行时,当条件不满足时,线程应进入等待状态;当条件满足时,线程再被唤醒,继续执行任务。否则可能会出现线程一直占用锁,其他线程无法执行的情况。

比如:

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
    }

    public synchronized String getTask() {
        while (queue.isEmpty()) {
        }
        return queue.remove();
    }
}

上面代码中,getTask()内部先判断队列是否为空,如果为空,就循环等待判断是否为空,直到另一个线程往队列中放入了一个任务,while()循环退出,就可以返回队列的元素了。

但实际上while()循环永远不会退出。因为线程在执行while()循环时,已经在getTask()入口获取了this锁,其他线程根本无法调用addTask(),因为addTask()执行条件也是获取this锁。

对于上述TaskQueue,我们应该改造getTask()方法,在条件不满足时,线程进入等待状态:

public synchronized String getTask() {
    while (queue.isEmpty()) {
        this.wait();  //进入等待状态。只能在锁对象上调用wait()方法
    }
    return queue.remove();
}

当一个线程执行到getTask()方法内部的while循环时,它必定已经获取到了this锁,此时,线程执行while条件判断,如果条件成立(队列为空),线程将执行this.wait(),进入等待状态。

wait()方法必须在当前获取的锁对象上调用,这里获取的是this锁,因此调用this.wait()。wait()方法的执行机制非常复杂。首先,它不是一个普通的Java方法,而是定义在Object类的一个native方法,也就是由JVM的C代码实现的。其次,必须在synchronized块中才能调用wait()方法,因为wait()方法调用时,会释放线程获得的锁,wait()方法返回后,线程又会重新试图获得锁。因此,只能在锁对象上调用wait()方法。因为在getTask()中,我们获得了this锁,因此,只能在this对象上调用wait()方法:

调用wait()方法后,线程进入等待状态,wait()方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()方法才会返回,然后,继续执行下一条语句。

当一个线程在this.wait()等待时,它就会释放this锁,从而使得其他线程能够在addTask()方法获得this锁。

 

9.2、唤醒线程(notify()、notifyAll())

我们可以在相同的锁对象上调用notify()、nofityAll()方法来让等待的线程从wait()方法返回,被重新唤醒。注意应该是在相同的锁对象上调用,并且这两个方法都只能用在 synchronized 方法或者 synchronized 代码块中,否则会报异常。

我们可以将addTask()方法改造如下:

public synchronized void addTask(String s) {
    this.queue.add(s);
    this.notify(); // 唤醒在this锁等待的线程
}

该方法会唤醒一个正在this锁等待的线程(就是在getTask()中位于this.wait()的线程),从而使得某一个等待线程从this.wait()方法返回。

要想唤醒所有当前正在this锁等待的线程,我们可以调用notifyAll()方法,notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。

 

posted @ 2020-05-01 16:29  wenxuehai  阅读(359)  评论(0编辑  收藏  举报
//右下角添加目录