缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题。
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
对于单核,所有的线程都是在一个CPU上执行,操作同一个CPU的缓存;一个线程对缓存的写,对另外一个线程来说一定是可见的。
例如在下面的图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量V的值,那么线程B之后再访问变量V,得到的一定是V的最新值(线程A写过的值)。
对于多核,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。这时两个线程对于变量的操作就不具备可见性了。
【示例】计数器的并发安全问题示例
Java的并发也是基于任务切换。Java中,即使是一条语句,也可能需要执行多条CPU指令。一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。
有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序。
【示例】双重检查创建单例对象
但是实际上优化后的执行路径却是这样的:
优化后会导致什么问题呢?我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。
导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但这种方案性能堪忧。
合理的方案应该是按需禁用缓存以及编译优化。Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括volatile、synchronized和final三个关键字,以及六项Happens-Before规则。
并发原子性问题的源头是线程切换。
解决这个问题的直接方法就是禁止线程切换。操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。这个方案对于单核场景是可行的,但不适用于多核场景。
举例来说,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位,如下图所示)。
在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。
“同一时刻只有一个线程执行”称之为互斥。如果能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。
一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时称这个线程持有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;其次,我们要保护资源R就得为它创建一把锁LR;最后,针对这把锁LR,我们还需在进出临界区时添上加锁操作和解锁操作。
Java中,synchronized是一种锁的实现方式。
【示例】synchronized使用示例
【示例】synchronized实现并发安全的计数器
受保护资源和锁之间的关联关系是N:1的关系。
【示例】synchronized实现并发安全的计数器错误示例
用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
【示例】账户类Account有两个成员变量,分别是账户余额balance和账户密码password。取款withdraw()和查看余额getBalance()操作会访问账户余额balance,创建一个final对象balLock作为锁(类比球赛门票);而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password,创建一个final对象pwLock作为锁(类比电影票)。不同的资源用不同的锁保护,各自管各自的。
答:不能用可变对象做锁。
【示例】保护临界区多个资源的错误示例
因此,可以优化为使用Class对象(Account.class)作为共享的锁。Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创建的,所以不用担心它的唯一性。
死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
【示例】存在死锁的示例
只有以下这四个条件都发生时才会出现死锁:
也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
通过一个Allocator来管理临界区。当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,需通知Allocator同时释放转出账户和转入账户这两个资源。
123//一次性申请转出账户和转入账户,直到成功while(!actr.apply(this,target));如果apply()操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的。但如果apply()操作耗时长,或者并发冲突量大的时候,可能遥循环大量次数才能获得锁,太消耗CPU了。
在这种场景下,更好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗CPU的问题。
核心是要能够主动释放它占有的资源。
synchronized做不到这点,但是可以通过Lock来解决此类问题。
破坏这个条件,需要对资源进行排序,然后按序申请资源。
假设每个账户都有不同的属性id,这个id可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
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。
等待-通知机制中,需要考虑以下四个要素。
并发编程中,需要注意三类问题:安全性问题、活跃性问题和性能问题。
并发安全问题的三个主要源头是:原子性、可见性、有序性。通俗的说,多线程同时读写共享变量。
对于非共享变量(ThreadLocal)或常量(final),不存在并发安全问题。
对于共享变量,在并发环境下,存在竞态条件。
对于这种情况,解决方案就是互斥(锁)。
活跃性问题主要分为:
三个核心性能指标:
由互斥而产生的阻塞会影响性能。要提升性能有以下思路:
synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以Java选择了管程。
管程,对应的英文是Monitor,很多Java领域的同学都喜欢将其翻译成“监视器”,这是直译。操作系统领域一般都翻译成“管程”。
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。
并发领域两大核心问题,管程都是能够解决的。
一个是互斥,即同一时刻只允许一个线程访问共享资源;
一个是同步,即线程之间如何通信、协作。
管程是如何解决互斥问题的:
管程是如何解决线程间的同步问题的:
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量A和条件变量B分别都有自己的等待队列。
通用的线程生命周期:初始状态、可运行状态、运行状态、休眠状态和终止状态。
Java中线程共有六种状态:
度量性能的核心指标:
创建多少线程合适,要看多线程具体的应用场景。
例如,有三个方法A、B、C,他们的调用关系是A->B->C(A调用B,B调用C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。
那调用栈和线程之间是什么关系呢?
答案是:每个线程都有自己独立的调用栈。
因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接Connection
将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。
对于这些不会发生变化的共享变量,建议你用final关键字来修饰。
识别共享变量间的约束条件非常重要。因为这些约束条件,决定了并发访问策略。
共享变量之间的约束条件,反映在代码里,基本上都会有if语句,所以,一定要特别注意竞态条件。
并发访问策略方案:
如何解决这三个问题呢?Java中提供了Java内存模型,以应对可见性和有序性问题;提供了互斥锁,以应对原子性问题。
互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题。
管程,是Java并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的。