该文章是一个系列文章,是本人在 Android 开发的漫漫长途上的一点感想和记录,如果能给各位看官带来一丝启发或者帮助,那真是极好的。
前言
前一篇 Android 并发编程开篇呢,主要是简单介绍一下线程以及 JMM,虽然文章不长,但却是理解后续文章的基础。本篇文章介绍多线程与锁。
深入认识 Java 中的 Thread
Thread 的三种启动方式上篇文章已经说了,下面呢,我们继续看看 Thread 这个类。
线程的状态
Java 中线程的状态分为 6 种。
初始 (NEW):新创建了一个线程对象,但还没有调用 start() 方法。
运行 (RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start() 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中状态(running)。
阻塞(BLOCKED):表示线程阻塞于锁。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时间后自行返回。
终止(TERMINATED):表示该线程已经执行完毕。
线程的几个常见方法的比较
Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入 TIMED_WAITING 状态,但不释放对象锁,millis 后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的 CPU 时间片,但不释放锁资源,由运行状态变为就绪状态,让 OS 再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证 yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与 sleep()类似,只是不能由用户指定暂停多长时间。
thread.join()/thread.join(long millis),当前线程里调用其它线程 thread 的 join 方法,当前线程进入 WAITING/TIMED_WAITING 状态,当前线程不会释放已经持有的对象锁。线程 thread 执行完毕或者 millis 时间到,当前线程进入就绪状态。
thread.interrupt(), 当前线程里调用其它线程 thread 的 interrupt()方法, 中断指定的线程。如果指定线程调用了 wait()方法组或者 join 方法组在阻塞状态,那么指定线程会抛出 InterruptedException
Thread.interrupted,一定是当前线程调用此方法,检查当前线程是否被设置了中断,该方法会重置当前线程的中断标志,返回当前线程是否被设置了中断。
thread.isInterrupted(),当前线程里调用其它线程 thread 的 isInterrupted()方法, 返回指定线程是否被中断
object.wait(),当前线程调用对象的 wait()方法,当前线程释放对象锁,进入等待队列。依靠 notify()/notifyAll()唤醒或者 wait(long timeout) timeout 时间到自动唤醒。
object.notify()唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。
线程安全
volatile 以及 synchronized 关键字
在上一篇博文中,各位看官已经对 JMM 模型有了初步的了解,我们在谈论线程安全的时候也无外乎解决上篇博文中提到的 3 个问题,原子性、可见性、时序性。
volatile
当一个共享变量被 volatile 修饰之后,其就具备了两个含义
线程修改了变量的值时,变量的新值对其他线程是立即可见的。换句话说,就是不同线程对这个变量进行操作时具有可见性。即该关键字保证了可见性
禁止使用指令重排序。这里提到了重排序,那么什么是重排序呢?重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。volatile 关键字禁止指令重排序有两个含
义:一个是当程序执行到 volatile 变量的操作时,在其前面的操作已经全部执行完毕,并且结果会对后面的操作可见,在其后面的操作还没有进行;在进行指令优化时,在 volatile 变量之前的语句不能在 volatile 变量后面执行;同样,在 volatile 变量之后的语句也不能在 volatile 变量前面执行。即该关键字保证了时序性
如何正确使用 volatile 关键字呢通常来说,使用 volatile 必须具备以下两个条件:
对变量的写操作不会依赖于当前值。例如自增自减
该变量没有包含在具有其他变量的不变式中。
synchronized
去面试 java 或者 Android 相关职位的时候个东西貌似是必问的,关于 synchronized 这个关键字真是有太多太多东西了。尤其是 JDK1.6 之后为了优化 synchronized 的性能,引入了偏向锁,轻量级锁等各种听起来就头疼的概念,java 还有 Android 面试世界流传着一个古老的名言,考察一个人对线程的了解成度的话,一个 synchronized 就足够了。不过本篇博文不讲那些,本篇博文本着让各位看官都能理解的初衷试着分析一下 synchronized 关键字把
重入锁 ReentrantLock
synchronized 关键字自动提供了锁以及相关的条件。大多数需要显式锁的情况使用 synchronized 非常方便,但是等我们了解了重入锁和条件对象时,能更好地理解 synchronized 关键字。重入锁 ReentrantLock 是 Java SE 5.0 引入的,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
try {
…
} finally {
reentrantLock.unlock();
}
如上代码所示,这一结构确保任何时刻只有一个线程进入临界区,临界区就是在同一时刻只能有一个任务访问的代码区。一旦一个线程封锁了锁对象,其他任何线程都无法进入 Lock 语句。把解锁的操作放在 finally 中是十分必要的。如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远被阻塞。
synchronized 关键字
我们再来看看 synchronized,synchronized 关键字有以下几种使用方式
同步方法(即直接在方法声明处加上 synchronized)
private synchronized void test() {
}
等价于
ReentrantLock reentrantLock = new ReentrantLock();
private void test() {
reentrantLock.lock();
try {
…
} finally {
reentrantLock.unlock();
}
}
同步代码块
上面我们说过,每一个 Java 对象都有一个锁,线程可以调用同步方法来获得锁。还有另一种机制可以获得锁,那就是使用一个同步代码块,如下所示:
synchronized(obj){
}
其获得了 obj 的锁,obj 指的是一个对象。同步代码块是非常脆弱的,通常不推荐使用。一般实现同步最 h 好用 java.util.concurrent 包下提供的类,比如阻塞队列。如果同步方法适合你的程序,那么请尽量使用同步方法,这样可以减少编写代码的数量,减少出错的概率。我们在代码中写的 synchronized(this){} 其实是与上面一样的,this 指代当前对象
静态方法加锁
static synchronized void test();
这种方式网上有人称它为“类锁”,其实这种说法有些迷惑人,我们只需要记住一点,所有的锁都是锁住的对象,也就是 Object 本身,你可以简单理解为使用 synchronized 是在堆内存中的某一个对象上加了一把锁,并且这个锁是可重入的,意思是说如果一个线程已经获得了某个对象的锁,那么该线程依然可以重新获得这把锁,但是其他线程如果想访问这个对象就必须等待上一个获得锁的线程释放锁。
我们在回过头来看静态方法加锁,为一个类的静态方法加锁,实际上等价于 synchronized(Class), 即锁定的是该类的 Class 对象。
线程同步
Object.wait() / Object.notify() Object.notifyAll()
任意一个 Java 对象,都拥有一组监视器方法(定义在 java.lang.Object 上),主要包括 wait()、wait(long timeout)、notify()以及 notifyAll()方法,这些方法与 synchronized 同步关键字配合,可以实现等待 / 通知模式
使用的前置条件
当我们想要使用 Object 的监视器方法时,需要或者该 Object 的锁,代码如下所示
synchronized(obj){
…. //1
obj.wait();//2
obj.wait(long millis);//2
….//3
}
一个线程获得 obj 的锁, 做了一些时候事情之后,发现需要等待某些条件的发生,调用 obj.wait(),该线程会释放 obj 的锁,并阻塞在上述的代码 2 处 obj.wait()和 obj.wait(long millis)的区别在于
obj.wait()是无限等待,直到 obj.notify()或者 obj.notifyAll()调用并唤醒该线程,该线程获取锁之后继续执行代码 3
obj.wait(long millis)是超时等待,我只等待 long millis 后,该线程会自己醒来,醒来之后去获取锁,获取锁之后继续执行代码 3
obj.notify()是叫醒任意一个等待在该对象上的线程,该线程获取锁,线程状态从 BLOCKED 进入 RUNNABLE
obj.notifyAll()是叫醒所有等待在该对象上的线程,这些线程会去竞争锁,得到锁的线程状态从 BLOCKED 进入 RUNNABLE,其他线程依然是 BLOCKED, 得到锁的线程执行代码 3 完毕后释放锁,其他线程继续竞争锁,如此反复直到所有线程执行完毕。
synchronized(obj){
…. //1
obj.notify();//2
obj.notifyAll();//2
}
一个线程获得 obj 的锁, 做了一些时候事情之后,某些条件已经满足,调用 obj.notify()或者 obj.notifyAll(),该线程会释放 obj 的锁,并叫醒在 obj 上等待的线程,obj.notify()和 obj.notifyAll()的区别在于
obj.notify()叫醒在 obj 上等待的任意一个线程(由 JVM 决定)
obj.notifyAll()叫醒在 obj 上等待的全部线程
使用范式
synchronized(obj){
// 判断条件,这里使用 while,而不使用 if
while(obj 满足 / 不满足 某个条件){
obj.wait()
}
}
放在 while 里面,是防止处于 WAITING 状态下线程监测的对象被别的原因调用了唤醒(notify 或者 notifyAll)方法,但是 while 里面的条件并没有满足(也可能当时满足了,但是由于别的线程操作后,又不满足了),就需要再次调用 wait 将其挂起
条件对象 Condition
JDK1.5 后提供了 Condition 接口,该接口定义了类似 Object 的监视器方法,与 Lock 配合可以实现等待 / 通知模式,但是这两者在使用方式以及功能特性上还是有差别的
public interface Condition {
// 等待 同 object.wait()
void await() throws InterruptedException;
// 无视中断等待 object 没有此类方法
void awaitUninterruptibly();
// 超时等待 同 object.wait(long millis)
long awaitNanos(long nanosTimeout) throws InterruptedException;
// 超时等待
boolean await(long time, TimeUnit unit) throws InterruptedException;
// 超时等待 到将来的某个时间 object 没有此类方法
boolean awaitUntil(Date deadline) throws InterruptedException;
// 通知 同 object.notify()
void signal();
// 通知 同 object.notifyAll()
void signalAll();
}
除了上述 API 之间的差别外,Condition 与 Object 的监视器方法显著的差别在于前置条件
wait 和 notify/notifyAll 方法只能在同步代码块里用(这个有的面试官也会考察)
Condition 接口对象需和 Lock 接口配合,通过 lock.lock()获取锁,lock.newCondition()获取条件对象更为灵活关于 Condition 接口的具体实现请往下看
LockSupport.park(Object blocker) / LockSupport.unpark(Thread thread)
上面说的 Condition 是一个接口,我们来看一下 Condition 接口的实现,Condition 接口的实现主要是通过另外一套等待 / 通知机制完成的。
LockSupport 定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而 LockSupport 也成为构建同步组件的基础工具。
LockSupport 定义了一组以 park 开头的方法用来阻塞当前线程,以及 unpark(Thread thread)方法来唤醒一个被阻塞的线程。
既然 JDK 已经提供了 Object 的 wait 和 notify/notifyAll 方法等方法,那么 LockSupport 定义的一组方法有何不同呢,我们来看下面这段代码就明白了
Thread A = new Thread(new Runnable() {
@Override
public void run() {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
try {
Thread.sleep(10000);// 睡眠 10s,保证 LockSupport.unpark(A); 先调用
} catch (InterruptedException e) {
e.printStackTrace();
}
// 直接调用 park 方法阻塞当前线程,没在同步方法或者代码块内
LockSupport.park(this);
System.out.println(sum);
}
});
A.start();
// 调用 unpark 方法唤醒指定线程,即使 unpark(Thread)方法先于 park 方法调用,依然能唤醒
LockSupport.unpark(A);
对比一下 Object 的 wait 和 notify/notifyAll 方法你就能明显看出区别
final Object obj = new Object();
Thread B = new Thread(new Runnable() {
@Override
public void run() {
synchronized (obj) {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
try {
Thread.sleep(10000);// 睡眠 10s,保证 obj.notify(); 先调用
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(sum);
}
}
});
B.start();
synchronized (obj) {
// 如果 obj.notify(); 先于 obj.wait()调用,那么调用调用 obj.wait()的线程会一直阻塞住
obj.notify();
}
在 LockSupport 的类说明上其实已经说明了 LockSupport 类似于 Semaphore,
Semaphore 是计数信号量。Semaphore 管理一系列许可证。每个 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。
Semaphore 经常用于限制获取某种资源的线程数量。
LockSupport 通过许可证来联系使用它的线程。如果许可证可用,调用 park 方法会立即返回并在这个过程中消费这个许可,不然线程会阻塞。调用 unpark 会使许可证可用。(和 Semaphores 有些许区别, 许可证不会累加,最多只有一张)因为有了许可证,所以调用 park 和 unpark 的先后关系就不重要了,
如何正确停止一个线程
讲解了上面那么多内容,现在出一个小小的笔试题,如何正确停止一个线程,别说是 thread.stop()哈,那个已经被标记过时了。如果您想参与这个问题请在评论区评论。
本篇总结
本篇主要是说了关于多线程与锁的东西。这里总结一下
volatile 保证了共享变量的可见性和禁止重排序,
Synchronized 的作用主要有三个:
(1)确保线程互斥的访问同步代码
(2)保证共享变量的修改能够及时可见(这个可能会被许多人忽略了)
(3)有效解决重排序问题。
从 JMM 上来说
被 volatile 修饰的共享变量如果被一个线程更改,那么会通知各个线程你们的副本已经过期了,赶快去内存拉取最新值吧
被 Synchronized 修饰的方法或者代码块,我们都知道会线程互斥访问,其实其有像 volatile 一样的效果,如果被一个线程更改了共享变量,在 Synchronized 结束处那么会通知各个线程你们的副本已经过期了,赶快去内存拉取最新值吧
由于笔者能力有限,如有不到之处,还请不吝赐教。
下篇预告
Java 中的原子类与并发容器
此致,敬礼