阿里架构师告诉你一些多线程的使用技巧

5次阅读

共计 11379 个字符,预计需要花费 29 分钟才能阅读完成。

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 Threadimplements 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) 池和等待池:

锁池(同步队列 SynchronizedQueue):假设线程 A 已经拥有了某个对象 (注意: 不是类) 的锁,而其它的线程想要调用这个对象的某个 synchronized 方法 (或者 synchronized 块),由于这些线程在进入对象的 synchronized 方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程 A 拥有,所以这些线程就进入了该对象的锁池中。等待池(等待队列 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 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

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

图片描述(最多 50 字)
在锁对象的对象头中有一个 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)。

图片描述(最多 50 字)
基本上,把这张图看懂,你也就知道 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 区别

ReentrantLock 可以中断地获取锁(void lockInterruptibly() throws InterruptedException)ReentrantLock 可以尝试非阻塞地获取锁(boolean tryLock())ReentrantLock 可以超时获取锁。通过 tryLock(timeout, unit),可以尝试获得锁,并且指定等待的时间。ReentrantLock 可以实现公平锁。通过 new ReentrantLock(true)实现。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 才能获取写锁进行数据更新。

Happens-Before 规则

程序顺序规则:如果程序中操作 A 在操作 B 之前,那么在线程中 A 操作将在 B 操作之前。监视器锁规则:一个 unlock 操作现行发生于后面对同一个锁的 lock 操作。volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。线程启动规则:Thread 对象的 start()方法先行发生于此线程的每一个动作。线程终止规则:线程的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值等于段检测到线程已经终止执行。线程中断规则:线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成。传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
注意:如果两个操作之间存在 happens-before 关系,并不意味着 java 平台的具体实现必须要按照 Happens-Before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法。

重排序

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

as-if-serial

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

说到最后给大家免费分享一波福利吧!我自己收集了一些 Java 资料,里面就包涵了一些 BAT 面试资料,以及一些 Java 高并发、分布式、微服务、高性能、源码分析、JVM 等技术资料
资料获取方式:请加群 BAT 架构技术交流群:171662117

正文完
 0