Android并发编程 多线程与锁
该文章是一个系列文章,是本人在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中的原子类与并发容器
此致,敬礼