并发概念
同步(Synchronous) 异步(Asynchronous)
- 同步和异步通常用来形容一次方法调用。同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。而异步方法通常会在另外一个线程中“真实”地执行。整个过程,不会阻碍调用者的工作。如果异步调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。
阻塞(Blocking) 非阻塞(NonBlocking)
- 阻塞和非阻塞通常用来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中进行等待。等待会导致线程挂起,这种情况就是阻塞。如果占用资源的线程一直不愿意释放资源,那么其他所有阻塞在这个临界区上的线程都不能工作。非阻塞的意思与之相反,它强调没有一个线程可以妨碍其他线程执行。所有的线程都会尝试不断前向执行。
死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)
- 死锁、饥饿和活锁都属于多线程的活跃性问题。
- 饥饿:指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。比如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源。或者某一个线程一直占着关键资源不放,导致其他需要这个资源的线程无法正常执行。
- 活锁:主动将资源释放给他人使用,那么就会出现资源不断在两个线程中跳动,而没有一个线程可以同时拿到所有资源而正常执行。
- 死锁:资源竞争而产生的相互等待的情况。主要原因是资源有限以及竞争不当。四个必要条件:互斥、占有且等待、不可抢占、循环等待(存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源)。避免死锁可以使用银行家算法。
并发(Concurrency)和并行(Parallelism)
- 严格意义上来说,并行的多个任务是真实的同时执行,而对于并发来说,这个过程只是交替的,一会儿运行任务A一会儿执行任务B,系统会不停地在两者间切换。但对于外部观察者来说,即使多个任务之间是串行并发的,也会造成多任务间是并行执行的错觉。
Java 并发编程基础
线程
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。
线程的状态
见于 Thread.State 枚举类:
初始(NEW)
- 新创建了一个线程对象,但还没有调用start()方法。运行(RUNNABLE)
- Java线程中将就绪(ready)
和运行中(running)
两种状态笼统的成为“运行中”。(等待被线程调度选中获取cpu的使用权,处于就绪状态(ready);就绪的线程在获得cpu 时间片后变为运行中状态(running))。阻塞(BLOCKED)
- 表示线程阻塞于锁。等待(WAITING)
- 进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。超时等待(TIME_WAITING)
- 该状态不同于WAITING,它可以在指定的时间内自行返回。终止(TERMINATED)
- 表示该线程已经执行完毕。
构造线程
新构造的线程对象是由其parent线程来进行空间分配的,而child线程继承了parent是否为Daemon、优先级和加载资源的 ContextClassLoader 以及可继承的ThreadLocal,同时还会分配一个唯一的ID来标识这个child线程。至此,一个能够运行的线程对象就初始化好了,在堆内存中等待着运行。
虽然可以使用匿名内部类重载run()方法来新建一个线程。但考虑到Java是单继承的,也就是说继承本身也是一种很宝贵的资源,因此,主要使用Runnable接口来实现同样的操作。
终止线程
stop()方法太过于暴力,强行把执行到一半的线程终止,并不会保证线程资源的正常释放,通常没有给予线程完成资源释放工作的机会,可能会引起一些数据不一致的问题,导致程序可能工作在不确定状态下。
suspend(), resume(), stop()都标注为过期方法,暂停以及恢复操作可以使用等待/通知机制代替。suspend()方法在导致线程暂停的同时,并不会去释放任何锁资源,任何线程想要访问锁都会被牵连,导致无法正常继续运行。
中断线程
中断可以理解为是线程的一个标志位属性。线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程,有人希望你退出。至于目标线程接到通知后如何处理,则完全由目标线程自行决定。
1 | // 中断线程 |
许多声名抛出InterruptedException的方法在抛出InterruptedException之前,Java虚拟机会先将该线程的中断标识位清除,然后抛出InterruptedException,此时调用isInterrupted()方法将会返回false。
1 | public static void main(String[] args) throws InterruptedException { |
如果线程被中断程序会抛出异常,进入catch子句,为了后续逻辑处理,执行了Thread.interrupt()方法再次中断自己,置上中断标记位。只有这么做,在中断检查中,才能发现当前线程已经被中断了。
安全地终止线程:中断操作是一种简单的线程间交互方式,此种交互方式最适合用来取消或停止任务。除了中断之外,还可以利用一个boolean变量控制是否需要停止任务并终止该线程。示例代码中可以通过标识位或者中断的方式使线程在终止时有机会去清理资源。
1 | public class Shutdown { |
线程组
ThreadGroup不属于Java并发包中的内容,它是java.lang中的内容。主要用于对线程方便进行统一管理,线程组可以进行复制,快速定位到一个线程,统一进行异常设置等。
activeCount()可以获得活动线程的总数,但由于线程是动态的,因此这个值只是一个估计值,无法确定精确,list()方法可以打印这个线程组中所有的线程信息,对调试有一定帮助。
ThreadGroup中有一个uncaughtException()方法。当线程组中某个线程发生Unchecked exception异常时,由执行环境调用此方法进行相关处理,如果有必要,可以重新定义此方法。
1 | public static void main(String[] args) { |
守护线程
Daemon线程:是一种支持型线程,主要用作程序后台调度以及支持性工作。当一个Java虚拟机中不存在非Daemon线程的守护,Java虚拟机会退出。可以通过在启动之前设置Thread.setDaemon(true)来设置。
构建Daemon线程时,不能依靠构建finally块中的内容来确保执行关闭或清理资源的逻辑。
1 | public class DaemonDemo { |
线程优先级
1 | public final static int MIN_PRIORITY = 1; |
优先级的范围是1~10,默认优先级是5。
线程的优先级调度和底层操作系统有密切的关系,在各个平台上表现不一,并且这种优先级产生的后果也可能不容易预测,无法精准控制,操作系统可能完全不会理会Java线程对优先级的设定。
synchronized
原理
同步代码块的实现会使用 monitorenter
和 monitorexit
指令,同步方法则依靠方法修饰符上的 ACC_SYNCHRONIZED 来完成的。无论是何种方式,本质上是对一个对象的监视器 (moitor) 进行获取,任何对象都有一个monitor与之关联,这个过程是排他的,每个对象都拥有自己的监视器,同一时刻只能一个线程获得对象监视器。
monitorenter 指令是在编译后插入到同步代码块的开始位置,monitorexit 是插入到方法结束处和异常处(JVM保证了每个monitorenter 有对应的 monitorexit 与之配对)。当执行 monitorenter 指令时,执行线程必须先获取到该对象的监视器才能进入,没有获取到监视器的线程会阻塞在同步块或同步方法的入口处没进入 BLOCKED 状态。
执行 monitorenter 时获取对象的锁,会把锁的计数器+1(可重入),在执行 monitorexit 时锁计数器会-1,当计数器为0会释放锁。
synchronized 用的锁是存在Java对象头里的,加锁本质就是在锁对象的对象头中写入当前线程id。
案例
当修饰静态方法的时候,锁定的是当前类的 Class 对象;
当修饰非静态方法的时候,锁定的是当前实例对象 this。
case1
1 | class SafeCalc { |
执行 addOne()方法后,value值对于get()方法是没有可见性保证的,需要在 get 方法上也加上 synchronized。
case2
1 | class SafeCalc { |
上面的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。
case3
1 | pulbic class Something { |
x.isSyncA()与x.isSyncB() 不能被同时访问。因为isSyncA()和isSyncB()都是访问同一个对象(对象x)的同步锁;
x.isSyncA()与y.isSyncA() 可以同时被访问。因为访问的不是同一个对象的同步锁,x.isSyncA()访问的是x的同步锁,而y.isSyncA()访问的是y的同步锁;
x.cSyncA()与y.cSyncB() 不能被同时访问。因为cSyncA()和cSyncB()都是static类型,x.cSyncA()相当于Something.isSyncA(),y.cSyncB()相当于Something.isSyncB(),因此它们共用一个同步锁,不能被同时反问;
x.isSyncA()与Something.cSyncA()可以被同时访问。因为isSyncA()是实例方法,x.isSyncA()使用的是对象x的锁;而cSyncA()是静态方法,Something.cSyncA()可以理解对使用的是“类的锁”。因此,它们是可以被同时访问的。
死锁
转账案例
1 | class Account { |
上述代码用于多Account转账是不正确的,因为 synchronized 使用的是 this 这把锁可以保护自己的余额 this.balance,但是保护不了别人的余额 target.balance。
所以可以使用 Account.class作为共享的锁。但会将所有Account的操作串行,性能会很差。
1 | class Account { |
于是可以采用如下方式,使用两把锁:
1 | class Account { |
但这样就会造成死锁,A要向B转账,B也要向A转账,同时拿到第一个锁,但无法等到第二个锁。
死锁发生条件
互斥
,共享资源 X 和 Y 只能被一个线程占用;占有且等待
,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;不可抢占
,其他线程不能强行抢占线程 T1 占有的资源;循环等待
,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
避免死锁
只要破坏上述其中一个条件,死锁就不会发生。互斥条件无法破坏,其他三种有方法破坏:
- 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
关于1,可以添加一个账本管理员,必须通过账本管理员拿到所有资源才会提供给相关方,通过这种方案保证一次申请所有资源。
1 | class Allocator { |
关于2,可以使用Lock。
关于3,可以对每个账号ID进行排序,按照从小到大的顺序锁账号,这样便不会出现循环等待的情况了。破坏循环等待条件的成本在这种场景下是最低的。
1 | class Account { |
如果Account对象中只有转账业务的话,while(actr.apply(this, target) 和 synchronized(Account.class)的性能优势几乎看不出来,synchronized(Account.class)方案由于 synchronized 三次,性能可能更差;但是如果Account对象中如果还有其它业务,比如查看余额等功能也加了synchronized(Account.class)修饰,那还是while的方式效率更高。此外,如果转账操作非常慢,也是while更有优势。
等待 / 通知 机制
线程A调用对象O的wait方法进入等待状态,线程B调用对象O的notify或notifyAll方法后,线程A收到通知后从对象O的wait方法返回,进而执行后续操作。
典型范式
等待方(加锁,循环,处理逻辑)
1)获取对象的锁。2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。3)条件满足则执行对应的逻辑。
1 | synchronized(对象) { |
之所以使用while loop,因为可能有其他线程执行对象的notify()或notify()方法,但是条件不满足。即“条件曾经满足过”。当 wait() 返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。
通知方
1)获得对象的锁。2)改变条件。3)通知所有等待在对象上的线程。
1 | synchronized(对象) { |
如何工作
- 调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列。
- notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING变为BLOCKED。
注意点:
- 使用wait()、notify()和notifyAll()时需要先对调用对象加锁。
- notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,要调用notify()或notifAll()的线程释放锁之后,等待线程才有机会从wait()返回。
- wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 其他target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:
java.lang.IllegalMonitorStateException
。
典型描述:
WaitThread首先获取了对象的锁,然后调用对象的wait()方法,从而放弃了锁并进入了对象的等待队列WaitQueue中,进入等待状态。由于WaitThread释放了对象的锁,NotifyThread随后获取了对象的锁,并调用对象的notify()方法,将WaitThread从WaitQueue移到SynchronizedQueue中,此时WaitThread的状态变为阻塞状态。NotifyThread释放了锁之后,WaitThread再次获取到锁并从wait()方法返回继续执行。
案例改写
将前文中提到的转账例子可以改写为:
1 | class Allocator { |
同时需尽量使用notifyAll()方法,notify()会随机通知一个等待队列中的一个线程,notifyAll()会通知等待队列中的所有线程。
依然基于上面的例子,假设线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列,线程 4 申请 CD 也会进入等待队列。假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。
join
一个线程A执行了thread.join()时代表当前线程A等待线程终止后才从thread.join()返回。
1 | public final void join() throws InterruptedException |
第一个join()方法表示无限等待,它会一直阻塞当前线程,直到目标线程执行完毕。第二个方法给出了一个最大等待时间,如果超过给定时间目标线程还在执行,当前线程也会因为“等不及了”,而继续往下执行。
其方法内部使用等待/通知机制,不停检查join线程是否存活,如果join线程存活则让当前线程永远等待。当join线程终止时,会调用线程自身的notifyAll()方法,通知所有等待在该线程上的线程。
1 | public final synchronized void join(long millis) throws InterruptedException { |
上述代码和等待 / 通知经典范式一致,即加锁、循环、处理逻辑三个步骤。
值得注意的一点是:不要在应用程序中,在Thread对象实例上使用类似wait()或者notify()等方法,因为这很有可能会影响系统API的工作,或者被系统API所影响。
yield
1 | public static native void yield(); |
Thread.yield() 是一个静态方法,一旦执行,它会使当前线程让出CPU。但要注意,让出 CPU 并不表示当前线程不执行了。当前线程在让出CPU后,还会进行CPU资源的争夺,但是是否能够再次被分配到,就不一定了。因此,对Thread.yield()的调用就好像是在说:我已经完成一些最重要的工作了,我应该是可以休息一下了,可以给其他线程一些工作机会。
如果一个线程不那么重要,或者优先级非常低,而且又害怕它会占用太多的CPU资源,那么可以在适当的时候调用Thread.yield(),给予其他重要线程更多的工作机会。
Case
下面代码会得到小20000000很多的数值,原因是Integer属于不变对象。也就是对象一旦被创建,就不可能被修改。 i++ 在真实执行时变成了 i=Integer.valueOf(i.intValue()+1);
i++ 的本质是,创建一个新的Integer对象,并将它的引用赋值给i。锁加到不同的对象实例上了。
1 | public class BadLockOnInteger implements Runnable { |
Reference
《实战Java高并发程序设计》
《Java并发编程的艺术》