并发学习笔记13-线程基础(下)
- 2020 年 1 月 22 日
- 笔记
线程间的通信
vloatile和synchronized关键字
Volatile可以修饰字段(成员变量),就是告知程序任何对该变量的访问均需从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。但是过多的使用volatile是不必要的,因为会降低程序执行的效率。
关键字synchronized可以修饰方法或以同步块的形式来使用,它主要确保多个线程在同一时刻,只能有一个线程处于方法或同步块中, 保证了线程对变量访问的可见性和排他性。
同步块的实现使用了monitorenter和monitorexit指令,而同步方法则依靠方法修饰符上的ACC_SYNCHRONIZED来完成。无论采用哪种方式,本质是对一个对象的监视器(monitor)进行获取,而这个获取过程是排他的,即同一个时刻只能有一个线程获取到由synchronized所保护对象的监视器。
每个对象都有自己的监视器,任意线程对由synchronized保护的Object的访问,首先要获取Objcet的监视器。若获取失败,线程进入同步队列线程状态变为BLOCKED。当访问Objcet的前驱(获取了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
等待/通知机制
一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,最终执行的是另一个线程。前者是生产者,后者是消费者。这种模式隔离了“做什么”和“怎么做”,在功能上实现了解耦,体系结构上具备了良好的伸缩性。
Java实现中简单的方法时让消费者线程不断地循环检查变量是否符合预期,如:
while(value != desire) { Thread.sleep(1000); } ......
上述伪代码在不符合条件时就睡眠一段时间,这样做的目的是防止过快的“无效”尝试,这种方式看似能实现所需的功能,但存在如下问题:
- 难以确保及时性。在睡眠时,基本不消耗处理器资源,但如果睡得过久,就不能及时发现条件已经改变。
- 难以降低开销。若降低睡眠时间,消费者能更快发现条件变化,但会消耗更多的处理器资源,造成无端浪费,
上面两个问题看似矛盾,但Java通过内置的等待/通知机制能够很好地解决这个矛盾并实现所需功能。
等待/通知的相关方法是任意对象都具备的,因为其定义在Object上,相关描述如下:
方法名称 |
描述 |
---|---|
notify() |
通知一个在对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁。 |
notifyAll() |
通知所有等待在该对象上的线程。 |
wait() |
调用该方法的线程进入WAITING状态,只有等待另外的线程的通知或被中断才会返回,需要注意,调用wait()方法后会释放对象的锁。 |
wait(long) |
超时等待一段时间,这里的参数时间时毫秒。等待n毫秒若没通知就超时返回。 |
wait(long,int) |
对于超时时间更细腻的控制,可达到纳秒。 |
等待/通知机制是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。
调用wait、notify、notifyAll需要注意一些细节:
- 使用wait()、notify()、notifyAll()时需要先对调用对象加锁。
- 调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列。
- notify()或notifyAll()方法调用后,等待线程依旧不会从wait()方法返回,需要调用notify()或notifyAll()的线程释放锁之后,才有机会从wait()返回。
- notify()方法将等待队列中的一个等待线程中等待队列中移到同步队列,被移动的线程状态由WAITING变为BLOCKED。
- 从wait()返回的前提是获取了调用对象的锁。
从上述可看到,等待/通知机制依托于同步机制,其目的就是确保等待线程从wait()方法返回时能够感知到通知线程对变量做出的修改。
等待/通知的经典范式
该范式分为两部分,分别针对等待方(消费者)和通知方(生产者)。
等待方遵守如下规则:
- 获取对象的锁。
- 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
- 条件满足则执行对应的逻辑。
通知方遵守如下原则:
- 获取对象的锁。
- 改变条件。
- 通知所有等待在对象上的线程。
管道输入/输出流
管道输入/输出流和普通文件的输入/输出流或网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。
管道输入/输出流主要包括了如下4种具体实现:PipedOutputStrem、PipedInputStrem、PipedReader和PipedWriter,前两种面向字节,后两种面向字符。
对于Piped类型的流,必须先要进行绑定,也就是调用connect()方法,如果没有将输入/输出流绑定起来,对于该流的访问将会抛出异常。
Thread.join()的使用
如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。线程还提供了join(long millis)和join(long millis, int naons)两个具有超时特性的方法。其表示若线程thread在超时时间里没有返回,将会从该超时方法中返回默认结果。
每个线程拥有前一个线程的引用,需等待前一个线程终止,才能从等待中返回。每个线程终止的前提是前驱线程的终止,等待终止后,才从join()方法返回。
当线程终止时,会调用线程自身的notifyAll()方法,会通知所有等待线程在该线程对象上的线程。join()方法的逻辑结构与等待/通知经典范式一直,即加锁、循环、处理逻辑3个步骤。
ThreadLocal的使用
ThreadLocal即是线程变量,是一个以ThreadLocal对象为键,任意对象为值的存储结构。该结构被附带在线程上,即一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。
可以通过set(T)方法来设置一个值,在当前线程调用get()方法获取到原先设置的值。
线程应用实例
等待超时模式
超时等待模式就是在等待/通知范式基础上增加了超时控制,这使得该模式相比原因范式更具有灵活性,因为即使方法执行时间长,也不会“永久”阻塞调用者,而是会按照调用者的要求“按时”返回。
可以用该模式来构建一个简单的数据库连接池。
线程池技术预先创建了若干数量的线程,并且不能由用户直接对线程的创建进行控制,在这个前提下重复使用固定或较为固定数目的线程来完成任务的执行。这样做的好处是:
- 一方面,消除了频繁创建和消亡线程的系统资源开销。
- 另一方面,面对过量任务的提交能够平缓的劣化。
线程池的本质是:
- 使用了一个线程安全的工作队列连接工作者线程和客户端线程。
- 客户端线程将任务放入工作队列后便返回,而工作者线程则不断地从工作队列上取出工作并执行。
- 当工作队列为空时,所有的工作者线程均等待在工作队列上,当有客户端提交了一个任务之后会通知任意一个工作者线程。
- 随着大量的任务被提交,更多的工作者线程会被唤醒。
线程池中线程的具体数量需要评估每个任务的处理时间,以及当前计算机的处理器能力和数量。过少无法发挥处理器的性能,过多会增加系统的无故开销,从而起到反作用。