多线程小抄集(新编一)

多线程小抄集(新编一)

Java中线程的状态

NEW、RUNNABLE(RUNNING or READY)、BLOCKED、WAITING、TIME_WAITING、TERMINATED

Java将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态,但是阻塞在JUC包中Lock接口的线程状态却是等待状态,因为JUC中Lock接口对于阻塞的实现是通过LockSupport类中的相关方法实现的。

线程的优先级

Java中线程的优先级分为1-10这10个等级,如果小于1或大于10则JDK抛出IllegalArgumentException()的异常,默认优先级是5。在Java中线程的优先级具有继承性,比如A线程启动B线程,则B线程的优先级与A是一样的。注意程序正确性不能依赖线程的优先级高低,因为操作系统可以完全不理会Java线程对于优先级的决定。

守护线程

Java中有两种线程:一种是用户线程,另一种是守护线程。当进程中不存在非守护线程了,则守护线程自动销毁。通过setDaemon(true)设置线程为后台线程。注意thread.setDaemon(true)必须在thread.start()之前设置,否则会报IllegalThreadStateException异常;在Daemon线程中产生的新线程也是Daemon的;在使用ExecutorService等多线程框架时,会把守护线程转换为用户线程,并且也会把优先级设置为Thread.NORM_PRIORITY。在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。

构造线程

一个新构造的线程对象是由其parent线程来进行空间分配的,而child线程继承了parent是否为Daemon、优先级、ThreadGroup、加载资源的contextClassLoader以及可继承的ThreadLocal(InheritableThreadLocal)、同时还会分配一个唯一的ID来标识这个child线程。

同步不具备继承性

当一个线程执行的代码出现异常时,其所持有的锁会自动释放。同步不具有继承性(声明为synchronized的父类方法A,在子类中重写之后并不具备synchronized的特性)。

使用多线程的方式

  • extends Thread
  • implements Runnable
  • 使用Future和Callable

Executor框架使用Runnable作为基本的任务表示形式。Runnable是一种有很大局限的抽象,虽然run能写入到日志文件或者将结果放入某个共享的数据结构,但它不能返回一个值或抛出一个受检查的异常。许多任务实际上都是存在延迟的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable是一种更好的抽象:它认为主入口点(call())将返回一个值,并可能抛出一个异常。Runnable和Callable描述的都是抽象的计算任务。这些任务通常是有范围的,即都有一个明确的起始点,并且最终会结束。

Thread.yield()方法

yield()方法的作用是放弃当前的CPU资源,将它让给其他的任务去占用CPU执行时间。但放弃时间不确定,有可能刚刚放弃,马上又获得CPU时间片。这里需要注意的是yield()方法和sleep()方法一样,线程并不会让出锁,和wait()不同,这一点也是为什么sleep()方法被设计在Thread类中而不在Object类中的原因。

Thread.sleep(0)

在线程中,调用sleep(0)可以释放CPU时间,让线程马上重新回到就绪队列而非等待队列,sleep(0)释放当前线程所剩余的时间片(如果有剩余的话),这样可以让操作系统切换其他线程来执行,提升效率。

The semantics of Thread.yield and Thread.sleep(0) are undefined [JLS17.9]; the JVM is free to implement them as no-ops or treat them as scheduling hints. In particular, they are not required to have the semantics of sleep(0) on Unix systems — put the current thread at the end of the run queue for that priority, yielding to other threads of the same priority — though some JVMs implement yield in this way.

Thread.join()

如果一个线程A执行了thread.join语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。join与synchronized的区别是:join在内部使用wait()方法进行等待,而synchronized关键字使用的是“对象监视器”做为同步。join提供了另外两种实现方法:join(long millis)和join(long millis, int nanos),至多等待多长时间而退出等待(释放锁),退出等待之后还可以继续运行。内部是通过wait方法来实现的。

wait, notify, notifyAll用法

只能在同步方法或者同步块中使用wait()方法。在执行wait()方法后,当前线程释放锁(这点与sleep和yield方法不同)。调用了wait函数的线程会一直等待,直到有其它线程调用了同一个对象的notify或者notifyAll方法才能被唤醒,需要注意的是:被唤醒并不代表立刻获得对象的锁,要等待执行notify()方法的线程执行完,即退出synchronized代码块后,当前线程才会释放锁,而呈wait状态的线程才可以获取该对象锁。如果调用wait()方法时没有持有适当的锁,则抛出IllegalMonitorStateException,它是RuntimeException的一个子类,因此,不需要try-catch语句进行捕获异常。notify()方法只会(随机)唤醒一个正在等待的线程,而notifyAll()方法会唤醒所有正在等待的线程。如果一个对象之前没有调用wait方法,那么调用notify方法是没有任何影响的。带参数的wait(long timeout)或者wait(long timeout, int nanos)方法的功能是等待某一时间内是否有线程对锁进行唤醒,如果超过这个时间则自动唤醒。

setUncaughtExceptionHandler

当单线程的程序发生一个未捕获的异常时我们可以采用try….catch进行异常的捕获,但是在多线程环境中,线程抛出的异常是不能用try….catch捕获的,这样就有可能导致一些问题的出现,比如异常的时候无法回收一些系统资源,或者没有关闭当前的连接等等。Thread的run方法是不抛出任何检查型异常的,但是它自身却可能因为一个异常而被中止,导致这个线程的终结。在Thread ApI中提供了UncaughtExceptionHandler,它能检测出某个由于未捕获的异常而终结的情况。

thread.setUncaughtExceptionHandler(new UncaughtExceptionHandler(){});

同样可以为所有的Thread设置一个默认的UncaughtExceptionHandler,通过调用Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)方法,这是Thread的一个static方法。在线程池中,只有通过execute()提交的任务,才能将它抛出的异常交给UncaughtExceptionHandler,而通过submit()提交的任务,无论是抛出的未检测异常还是已检查异常,都将被认为是任务返回状态的一部分。如果既包含setUncaughtExceptionHandler又包含setDefaultUncaughtExceptionHandler,那么会被setUncaughtExceptionHandler处理,setDefaultUncaughtExceptionHandler则忽略。

关闭钩子

JVM既可以正常关闭也可以强制关闭,或者说非正常关闭。关闭钩子可以在JVM关闭时执行一些特定的操作,譬如可以用于实现服务或应用程序的清理工作。关闭钩子可以在以下几种场景中应用:1. 程序正常退出(这里指一个JVM实例);2.使用System.exit();3.终端使用Ctrl+C触发的中断;4. 系统关闭;5. OutOfMemory宕机;6.使用Kill pid命令干掉进程(注:在使用kill -9 pid时,是不会被调用的)。使用方法(Runtime.getRuntime().addShutdownHook(Thread hook))。

终结器finalize

终结器finalize:在回收器释放它们后,调用它们的finalize方法,从而保证一些持久化的资源被释放。在大多数情况下,通过使用finally代码块和显示的close方法,能够比使用终结器更好地管理资源。唯一例外情况在于:当需要管理对象,并且该对象持有的资源是通过本地方法获得的。但是基于一些原因(譬如对象复活),我们要尽量避免编写或者使用包含终结器的类。

管道

在Java中提供了各种各样的输入/输出流Stream,使我们能够很方便地对数据进行操作,其中管道流(pipeStream)是一种特殊的流,用于在不同线程间直接传送数据。一个线程发送数据到输出管道,另一个线程从输入管道中读数据,通过使用管道,实现不同线程间的通信,而无须借助类似临时文件之类的东西。在JDK中使用4个类来使线程间可以进行通信:PipedInputStream, PipedOutputStream, PipedReader, PipedWriter。使用代码类似inputStream.connect(outputStream)或outputStream.connect(inputStream)使两个Stream之间产生通信连接。

几种进程间的通信方式

  • 管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  • 有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  • 信号量( semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  • 消息队列( message queue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
  • 共享内存( shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
  • 套接字( socket ) : 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。

synchronized的类锁与对象锁

类锁:在方法上加上static synchronized的锁,或者synchronized(xxx.class)的锁。如下代码中的method1和method2:

对象锁:参考method4,method5,method6。

public class LockStrategy
{
public Object object1 = new Object();

public static synchronized void method1(){}
public void method2(){
synchronized(LockStrategy.class){}
}

public synchronized void method4(){}
public void method5()
{
synchronized(this){}
}
public void method6()
{
synchronized(object1){}
}
}

注意方法method4和method5中的同步块也是互斥的。

下面做一道习题来加深一下对对象锁和类锁的理解,有一个类这样定义:

public class SynchronizedTest
{
public synchronized void method1(){}
public synchronized void method2(){}
public static synchronized void method3(){}
public static synchronized void method4(){}
}

那么,有SynchronizedTest的两个实例a和b,对于一下的几个选项有哪些能被一个以上的线程同时访问呢?

A. a.method1() vs. a.method2()
B. a.method1() vs. b.method1()
C. a.method3() vs. b.method4()
D. a.method3() vs. b.method3()
E. a.method1() vs. a.method3()

答案是什么呢?BE。

ReentrantLock

ReentrantLock提供了tryLock方法,tryLock调用的时候,如果锁被其他线程持有,那么tryLock会立即返回,返回结果为false;如果锁没有被其他线程持有,那么当前调用线程会持有锁,并且tryLock返回的结果为true。

boolean tryLock()
boolean tryLock(long timeout, TimeUnit unit)

可以在构造ReentranLock时使用公平锁,公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。synchronized中的锁时非公平的,默认情况下ReentrantLock也是非公平的,但是可以在构造函数中指定使用公平锁。

ReentrantLock()
ReentrantLock(boolean fair)

对于ReentrantLock来说,还有一个十分实用的特性,它可以同时绑定多个Condition条件,以实现更精细化的同步控制。ReentrantLock使用方式如下:

Lock lock = new ReentrantLock();
lock.lock();
try{
}finally{
lock.unlock();
}

在finally块中释放锁,目的是保证在获取到锁之后,最终能够释放。不要将获取锁的过程写在try块中,因为如果在获取锁时发生了异常,异常抛出的同时也会导致锁无故释放。IllegalMonitorStateException。

公平锁和非公平锁只有两处不同

  • 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
  • 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。

公平锁和非公平锁就这两点区别,如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒。相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。

synchronized

在Java中,每个对象都有两个池,锁(monitor)池和等待池:

  1. 锁池(同步队列SynchronizedQueue):假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。
  2. 等待池(等待队列WaitQueue):假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁(因为wait()方法必须出现在synchronized中,这样自然在执行wait()方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池。

synchronized修饰的同步块使用monitorenter和monitorexit指令,而同步方法则是依靠方法修饰符上的ACC_SYNCHRONIZED来完成的。无论采用哪种方式,其本质上是对一个对象的监视器(monitor)进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获取到synchronized所保护对象的监视器。任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入同步块或者同步方法,而没有获取到监视器(执行该方法)的线程将会阻塞在同步块和同步方法的入口处,进入BLOCKED状态。

任意线程对Object(Synchronized)的访问,首先要获得Object的监视器。如果获取失败,线程进入同步队列(同步队列SynchronizedQueue),线程状态变为BLOCKED。当访问Object的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

wait方法调用后,线程状态由Runnable变为WAITING/TIME_WAITING,并将当前线程放置到对象的等待队列(等待队列WaitQueue)中。notify()方法是将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll方法是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING变为BLOCKED。

在锁对象的对象头中有一个threadId字段,当第一个线程访问锁时,如果该锁没有被其他线程访问过,即threadId字段为空,那么JVM让其持有偏向锁,并将threadId字段的值设置为该线程的ID。当下一次获取锁时,会判断当前线程ID是否与锁对象的threadId一致。如果一致,那么该线程不会再重复获取锁,从而提高了程序的运行效率。如果出现锁的竞争情况,那么偏向锁会被撤销并升级为轻量级锁。如果资源的竞争非常激烈,会升级为重量级锁。

Condition

一个Condition和一个Lock关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个Condition,可以在相关联的Lock上调用Lock.newCondition方法。正如Lock比内置加锁提供了更为丰富的功能,Condition同样比内置条件队列提供了更丰富的功能:在每个锁上可存在多个等待、条件等待可以是可中断的或者不可中断的、基于时限的等待,以及公平的或非公平的队列操作。对于每个Lock,可以有任意数量的Condition对象。Condition对象继承了相关的Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放。注意:在Condition对象中,与wait,notify和notifyAll方法对应的分别是await,signal,signalAll。但是Condition对Object进行了扩展,因而它也包含wait和notify方法。一定要确保使用的版本——await和signal。

Condition接口的定义:

public interface Condition{
void await() throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
void awaitUniterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}

AQS 中有一个同步队列(CLH),用于保存等待获取锁的线程的队列。这里我们引入另一个概念,叫等待队列(condition queue)。

基本上,把这张图看懂,你也就知道 condition 的处理流程了:1. 我们知道一个 ReentrantLock 实例可以通过多次调用 newCondition() 来产生多个 Condition 实例,这里对应 condition1 和 condition2。注意,ConditionObject 只有两个属性 firstWaiter 和 lastWaiter;2. 每个 condition 有一个关联的等待队列,如线程 1 调用 condition1.await() 方法即可将当前线程 1 包装成 Node 后加入到等待队列中,然后阻塞在这里,不继续往下执行,等待队列是一个单向链表;3. 调用 condition1.signal() 会将condition1 对应的等待队列的 firstWaiter 移到同步队列的队尾,等待获取锁,获取锁后 await 方法返回,继续往下执行。

ReentrantLock与synchonized区别

  1. ReentrantLock可以中断地获取锁(void lockInterruptibly() throws InterruptedException)
  2. ReentrantLock可以尝试非阻塞地获取锁(boolean tryLock())
  3. ReentrantLock可以超时获取锁。通过tryLock(timeout, unit),可以尝试获得锁,并且指定等待的时间。
  4. ReentrantLock可以实现公平锁。通过new ReentrantLock(true)实现。
  5. ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的的wait(), notify(), notifyAll()方法可以实现一个隐含条件,如果要和多于一个的条件关联的对象,就不得不额外地添加一个锁,而ReentrantLock则无需这样做,只需要多次调用newCondition()方法即可。

Lock接口中的方法:

void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();

重入锁的实现原理

为每个锁关联一个请求计数和占有他的线程

synchronized与ReentrantLock之间进行选择

ReentrantLock与synchronized相比提供了许多功能:定时的锁等待,可中断的锁等待、公平锁、非阻塞的获取锁等,而且从性能上来说ReentrantLock比synchronized略有胜出(JDK6起),在JDK5中是远远胜出,为嘛不放弃synchronized呢?ReentrantLock的危险性要比同步机制高,如果忘记在finally块中调用unlock,那么虽然代码表面上能正常运行,但实际上已经埋下了一颗定时炸弹,并很可能伤及其他代码。仅当内置锁不能满足需求时,才可以考虑使用ReentrantLock。

读写锁ReentrantReadWriteLock

读写锁表示也有两个锁,一个是读操作相关的锁,也称为共享锁;另一个是写操作相关的锁,也叫排它锁。也就是多个读锁之间不互斥,读锁与写锁互斥,写锁与写锁互斥。在没有Thread进行写操作时,进行读取操作的多个Thread都可以获取读锁,而进行写入操作的Thread只有在获取写锁后才能进行写入操作。即多个Thread可以同时进行读取操作,但是同一时刻只允许一个Thread进行写入操作。(lock.readlock.lock(), lock.readlock.unlock, lock.writelock.lock, lock.writelock.unlock)

锁降级是指写锁降级成读锁。如果当前线程拥有写锁,然后将其释放,最后获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,最后释放(先前拥有的)写锁的过程。锁降级中的读锁是否有必要呢?答案是必要。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

重排序

是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

as-if-serial

不管怎么重排序,单线程程序的执行结构不能被改变。


欢迎支持笔者的作品《深入理解Kafka: 核心设计与实践原理》和《RabbitMQ实战指南》,同时欢迎关注笔者的微信公众号:朱小厮的博客(ID: hiddenkafka)。
本文作者: 朱小厮

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×