第五章、Java线程间的通信
合理的使用Java多线程可以更好地利用服务器资源。一般来讲,线程内部有自己私有的线程上下文,互不干扰
在Java中,锁的概念都是基于对象的,所以我们又经常称它为对象锁。线程和锁的关系,一个锁同一时间只能被一个线程持有。也就是说,一个锁如果被一个线程持有,那其他线程如果需要得到这个锁,就得等这个线程和这个锁释放。
在我们的线程之间,有一个同步的概念。什么是同步呢,假如我们现在有2位正在抄暑假作业答案的同学:线程A和线程B。当他们正在抄的时候,老师突然来修改了一些答案,可能A和B最后写出的暑假作业就不一样。我们为了A,B能写出2本相同的暑假作业,我们就需要让老师先修改答案,然后A,B同学再抄。或者A,B同学先抄完,老师再修改答案。这就是线程A,线程B的线程同步。
可以以解释为:线程同步是线程之间按照一定的顺序执行。
如何保证线程同步:为了达到线程同步,我们可以使用锁来实现它。
public class NoneLock { static class ThreadA implements Runnable { @Override public void run() { for (int i = 0; i < 100; i++) { System.out.println("Thread A " + i); } } } static class ThreadB implements Runnable { @Override public void run() { for (int i = 0; i < 100; i++) { System.out.println("Thread B " + i); } } } public static void main(String[] args) { new Thread(new ThreadA()).start(); new Thread(new ThreadB()).start(); } }
执行这个程序,你会在控制台看到,线程A和线程B各自独立工作,输出自己的打印值。如下是我的电脑上某一次运行的结果。每一次运行结果都会不一样。
.... Thread A 48 Thread A 49 Thread B 0 Thread A 50 Thread B 1 Thread A 51 Thread A 52 ....
那我现在有一个需求,我想等A先执行完之后,再由B去执行,怎么办呢?最简单的方式就是使用一个“对象锁”:
public class ObjectLock { private static Object lock = new Object(); static class ThreadA implements Runnable { @Override public void run() { synchronized (lock) { for (int i = 0; i < 100; i++) { System.out.println("Thread A " + i); } } } } static class ThreadB implements Runnable { @Override public void run() { synchronized (lock) { for (int i = 0; i < 100; i++) { System.out.println("Thread B " + i); } } } } public static void main(String[] args) throws InterruptedException { new Thread(new ThreadA()).start(); Thread.sleep(10); new Thread(new ThreadB()).start(); } }
这里声明了一个名字为lock
的对象锁。我们在ThreadA
和ThreadB
内需要同步的代码块里,都是用synchronized
关键字加上了同一个对象锁lock
。
上文我们说到了,根据线程和锁的关系,同一时间只有一个线程持有一个锁,那么线程B就会等线程A执行完成后释放lock
,线程B才能获得锁lock
。
这里在主线程里使用sleep方法睡眠了10毫秒,是为了防止线程B先得到锁。因为如果同时start,线程A和线程B都是出于就绪状态,操作系统可能会先让B运行。这样就会先输出B的内容,然后B执行完成之后自动释放锁,线程A再执行。
上面一种基于“锁”的方式,线程需要不断地去尝试获得锁,如果失败了,再继续尝试。这可能会耗费服务器资源。而等待/通知机制是另一种方式。
Java多线程的等待/通知机制是基于Object类的wait()方法和notify(), notifyAll()方法来实现的。
前面讲到,一个锁同一时刻只能被一个线程持有。而假如线程A现在持有了一个锁lock
并开始执行,它可以使用lock.wait()
让自己进入等待状态。这个时候,lock
这个锁是被释放了的。这时,线程B获得了lock这个锁并开始执行,它可以在某一时刻,使用lock.notify(),通知之前持有lock锁并进入等待状态的线程A,说“线程A你不用等了,可以往下执行了”。需要注意的是,这个时候线程B并没有释放锁lock,除非线程B这个时候使用lock.wait()释放锁,或者线程B执行结束自行释放锁,线程A才能得到lock锁。
我们用代码来实现一下:
public class WaitAndNotify { private static Object lock = new Object(); static class ThreadA implements Runnable { @Override public void run() { synchronized (lock) { for (int i = 0; i < 5; i++) { try { System.out.println("ThreadA: " + i); lock.notify(); lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } lock.notify(); } } } static class ThreadB implements Runnable { @Override public void run() { synchronized (lock) { for (int i = 0; i < 5; i++) { try { System.out.println("ThreadB: " + i); lock.notify(); lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } lock.notify(); } } } public static void main(String[] args) throws InterruptedException { new Thread(new ThreadA()).start(); Thread.sleep(1000); new Thread(new ThreadB()).start(); } }
// 输出: ThreadA: 0 ThreadB: 0 ThreadA: 1 ThreadB: 1 ThreadA: 2 ThreadB: 2 ThreadA: 3 ThreadB: 3 ThreadA: 4 ThreadB: 4
在这个Demo里,线程A和线程B首先打印出自己需要的东西,然后使用notify()
方法叫醒另一个正在等待的线程,然后自己使用wait()
方法陷入等待并释放lock
锁。
需要注意的是等待/通知机制是使用同一个对象锁,如果你两个线程使用的是不同的对象锁,那它们之间是不能用等待/通知机制通信的。
三、信号量
JDK提供了一个类似于“信号量”功能的类Semaphore。但本文不是要介绍这个类,而是介绍一种基于volatile
关键字的自己实现的信号量通信。
volatile关键字能够保证内存的可见性,如果用volatile关键字声明了一个变量,在一个线程里面改变了这个变量的值,那其它线程是立马可见更改后的值的。
比如我现在有一个需求,我想让线程A输出0,然后线程B输出1,再然后线程A输出2…以此类推。我应该怎样实现呢?
代码:
public class Signal { private static volatile int signal = 0; static class ThreadA implements Runnable { @Override public void run() { while (signal < 5) { if (signal % 2 == 0) { System.out.println("threadA: " + signal); signal++; } } } } static class ThreadB implements Runnable { @Override public void run() { while (signal < 5) { if (signal % 2 == 1) { System.out.println("threadB: " + signal); signal = signal + 1; } } } } public static void main(String[] args) throws InterruptedException { new Thread(new ThreadA()).start(); Thread.sleep(1000); new Thread(new ThreadB()).start(); } }
// 输出: threadA: 0 threadB: 1 threadA: 2 threadB: 3 threadA: 4
我们可以看到,使用了一个volatile
变量signal
来实现了“信号量”的模型。这里需要注意的是,volatile
变量需要进行原子操作。需要注意的是,signal++
并不是一个原子操作,所以我们在实际开发中,会根据需要使用synchronized
给它“上锁”,或者是使用AtomicInteger
等原子类。
这种实现方式并不一定高效,本例只是演示信号量
信号量的应用场景:
-
- 假如在一个停车场中,车位是我们的公共资源,线程就如同车辆,而看门的管理员就是起的“信号量”的作用。
- 因为在这种场景下,多个线程(超过2个)需要相互合作,我们用简单的“锁”和“等待通知机制”就不那么方便了。这个时候就可以用到信号量。
- 其实JDK中提供的很多多线程通信工具类都是基于信号量模型的。我们会在后面第三篇的文章中介绍一些常用的通信工具类。
四、管道
管道是基于“管道流”的通信方式。JDK提供了PipedWriter 、PipedReader、PipedOutputStream、 PipedInputStream。其中,前面两个是基于字符的,后面两个是基于字节流的。
这里的示例代码使用的是基于字符的:
public class Pipe { static class ReaderThread implements Runnable { private PipedReader reader; public ReaderThread(PipedReader reader) { this.reader = reader; } @Override public void run() { System.out.println("this is reader"); int receive = 0; try { while ((receive = reader.read()) != -1) { System.out.print((char)receive); } } catch (IOException e) { e.printStackTrace(); } } } static class WriterThread implements Runnable { private PipedWriter writer; public WriterThread(PipedWriter writer) { this.writer = writer; } @Override public void run() { System.out.println("this is writer"); int receive = 0; try { writer.write("test"); } catch (IOException e) { e.printStackTrace(); } finally { try { writer.close(); } catch (IOException e) { e.printStackTrace(); } } } } public static void main(String[] args) throws IOException, InterruptedException { PipedWriter writer = new PipedWriter(); PipedReader reader = new PipedReader(); writer.connect(reader); // 这里注意一定要连接,才能通信 new Thread(new ReaderThread(reader)).start(); Thread.sleep(1000); new Thread(new WriterThread(writer)).start(); } } // 输出: this is reader this is writer test
我们通过线程的构造函数,传入了PipedWrite
和PipedReader
对象。可以简单分析一下这个示例代码的执行流程:
-
线程ReaderThread开始执行,
-
线程ReaderThread使用管道reader.read()进入”阻塞“,
-
线程WriterThread开始执行,
-
线程WriterThread用writer.write(“test”)往管道写入字符串,
-
线程WriterThread使用writer.close()结束管道写入,并执行完毕,
-
线程ReaderThread接受到管道输出的字符串并打印,
-
线程ReaderThread执行完毕。
-
管道通信的应用场景:
这个很好理解。使用管道多半与I/O流相关。当我们一个线程需要先另一个线程发送一个信息(比如字符串)或者文件等等时,就需要使用管道通信了。
五、其它通信相关
以上介绍了一些线程间通信的基本原理和方法。除此以外,还有一些与线程通信相关的知识点,这里一并介绍。
5.1、join方法
join()方法是Thread类的一个实例方法。它的作用是让当前线程陷入“等待”状态,等join的这个线程执行完成后,再继续执行当前线程。有时候,主线程创建并启动了子线程,如果子线程中需要进行大量的耗时运算,主线程往往将早于子线程结束之前结束。如果主线程想等待子线程执行完毕后,获得子线程中的处理完的某个数据,就要用到join方法了。
示例代码:
public class Join { static class ThreadA implements Runnable { @Override public void run() { try { System.out.println("我是子线程,我先睡一秒"); Thread.sleep(1000); System.out.println("我是子线程,我睡完了一秒"); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(new ThreadA()); thread.start(); thread.join(); System.out.println("如果不加join方法,我会先被打出来,加了就不一样了"); } }
实际上,通过源码你会发现,join()方法及其重载方法底层都是利用了wait(long)这个方法。
5.2、sleep方法
sleep方法是Thread类的一个静态方法。它的作用是让当前线程睡眠一段时间。它有这样两个方法:
-
-
Thread.sleep(long)
-
Thread.sleep(long, int)
-
同样,查看源码(JDK 1.8)发现,第二个方法貌似只对第二个参数做了简单的处理,没有精确到纳秒。实际上还是调用的第一个方法。这里需要强调一下:sleep方法是不会释放当前的锁的,而wait方法会。这也是最常见的一个多线程面试题。
它们还有这些区别:
-
-
wait可以指定时间,也可以不指定;而sleep必须指定时间。
-
wait释放cpu资源,同时释放锁;sleep释放cpu资源,但是不释放锁,所以易死锁。
-
wait必须放在同步块或同步方法中,而sleep可以再任意位置
-
5.3、ThreadLocal类
ThreadLocal是一个本地线程副本变量工具类。内部是一个弱引用的Map来维护。
有些朋友称ThreadLocal为线程本地变量或线程本地存储。严格来说,ThreadLocal类并不属于多线程间的通信,而是让每个线程有自己”独立“的变量,线程之间互不影响。它为每个线程都创建一个副本,每个线程可以访问自己内部的副本变量。
ThreadLocal类最常用的就是set方法和get方法。示例代码:
public class ThreadLocalDemo { static class ThreadA implements Runnable { private ThreadLocal<String> threadLocal; public ThreadA(ThreadLocal<String> threadLocal) { this.threadLocal = threadLocal; } @Override public void run() { threadLocal.set("A"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("ThreadA输出:" + threadLocal.get()); } static class ThreadB implements Runnable { private ThreadLocal<String> threadLocal; public ThreadB(ThreadLocal<String> threadLocal) { this.threadLocal = threadLocal; } @Override public void run() { threadLocal.set("B"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("ThreadB输出:" + threadLocal.get()); } } public static void main(String[] args) { ThreadLocal<String> threadLocal = new ThreadLocal<>(); new Thread(new ThreadA(threadLocal)).start(); new Thread(new ThreadB(threadLocal)).start(); } } } // 输出: ThreadA输出:A ThreadB输出:B
可以看到,虽然两个线程使用的同一个ThreadLocal实例(通过构造方法传入),但是它们各自可以存取自己当前线程的一个值。那ThreadLocal有什么作用呢?如果只是单纯的想要线程隔离,在每个线程中声明一个私有变量就好了呀,为什么要使用ThreadLocal?
如果开发者希望将类的某个静态变量(user ID或者transaction ID)与线程状态关联,则可以考虑使用ThreadLocal。最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等。数据库连接和Session管理涉及多个复杂对象的初始化和关闭。如果在每个线程中声明一些私有变量来进行操作,那这个线程就变得不那么“轻量”了,需要频繁的创建和关闭连接。