赞
踩
可见性是指:一个线程对共享变量修改,另一个线程会立马看到。
缓存导致的可见性问题。
原子性:一个或者多个操作在CPU执行过程中不被中断的特性
线程切换带来的原子性问题。
编译优化带来的有序性问题
以双重校验的单例模式为例
pulic class Singleton{
static Singleton instance;
static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
}
}
new 操作我们期望的流程是:
但是实际优化后的路径是
流程如下图所示:
主要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发bug都是可以理解、可以诊断的。
缓存、线程、编译优化的目的和我们写并发程序的目的相同,都是提高程序的性能。但是技术在解决一个问题的同时,比如会带来另一个问题,所以我们在采用一项技术的同事,一定要清楚它带来的问题是什么,以及如何规避。
在32位机器上对long型变量进行加减操作有并发隐患
Java如何解决有序性和可见性问题?TODO 置原子性与何地呢?
解决可见性和有序性的直接办法就是禁用缓存和编译优化,但是这样问题虽然解决了,但是性能堪忧。因此合理的方案是 按需禁用缓存和编译优化,那么何时禁止编译优化缓存和编译优化呢?只有程序员知道。因此,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
JMM是一个很复杂的规范,可以从不同角度解读,站在程序员角度,本质可以理解为:JMM规范了JVM如何按需提供禁用缓存和编译优化的方法,这些方法包括三个关键字volatile、synchronized和final和6个Happens-before规则。
volatile关键字并不是Java语言的特产,古老的C语言也有,最原始的意义是禁用CPU缓存。它表达的是告诉编译器,对这个变量的读写不能使用CPU缓存,必须从内存读取或者写入。从这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。
class VolatileExample{
int x = 0;
volatile boolean v = false;
public void writer(){
x = 42;
v = true;
}
public void reader(){
if(v == true){
//TODO 这里的X会是多少
}
}
}
如果A线程调用writer()方法,将v为true写入内存中,如果B线程调用reader,那么B线程中X的值会是多少? 直觉上来说,应该是42.这个要看JDK版本,如果是1.5之前版本,可能是42,也可能是0。但是如果是1.5之后的版本,x就是42。
分析一下,为什么1.5之前版本可能会出现x=0的情况?变量x可能被CPU缓存而导致可见性问题。在Java1.5版本 对volatile语义进行了增强。通过volatile的hanpens-before。
Happens-before 并不是说一个操作发生在后续操作的前面,它真正要表达的是前面一个操作的结果对后续操作是可见的。Happens-before规则就是保证线程间操作结果的可见性。
Happens-before约束了编译器的优化行为,虽然允许编译器优化,但是要求编译器优化一定遵循Happens-before规则
Happens-before规则应该是JMM最晦涩的内容,和程序员相关的有如下六项,都是关于可见性的。
1)程序的顺序性原则
在一个线程中,按照程序顺序,前面的操作Happens-Before后续的任意操作
2)volatile原则
volatile变量的写操作Happens-before与后续对这个变量的读操作
3)传递性
如果 A happens-before B , B happens-before C , 则A happens-before C.
4) 管程中的锁规则
对一个锁的解锁happens-before于后续对这个锁的加锁
5)线程start()原则
Thread B = new Thread(()->{
//主线程调用B.start()之前,所有对共享变量的修改,此处
//都是可见的
//此例中,var = 77
});
//此处对共享变量var进行了修改
var = 77;
//主线程启动子线程
B.start();
6 ) 线程join()原则
Thread B = new Thread(()->{
//主线程调用B.start()之前,所有对共享变量的修改,此处
//都是可见的
//此例中,var = 77
var = 100
});
//此处对共享变量var进行了修改
var = 77;
//主线程启动子线程
B.start();
B.join();
//子线程所有对共享变量的修改,在线程调用B.join()之后都是可见
//此例中,var == 66
volatile为的是禁用缓存和编译优化,那有没有办法告诉编译器优化得更好一点呢?那就是final。它修饰变量的初衷就是:这个变量生而不变,可以尽可能的优化
JMM主要分为两部分,一部分面向写并发程序的应用开发人员,另一部分是面向JVM实现人员的。我们更需要关注和应用开发相关的部分,这部分主要的就是Happens-before规则
有一个共享变量abc,在一个线程设置了abc=3,有哪些办法可以让其他线程看到"abc == 3"
解决原子性问题
原子性:一个或多个操作在CPU执行过程中不被中断的特性。
在32位的机器上,long类型变量的读写,是分为两个步骤的。
原子性问题的源头就是线程切换,如果能够禁止线程切换,就能够解决该问题。操作系统做线程切换依赖的是CPU中断的,所以禁止CPU中断就能够禁止线程切换,在单核CPU时代,这种方案是可行的,但是不合适多核使用场景。我们还是long类型在32位机器上的读写为例:
在单核CPU上,同一时刻只有一个线程执行,如果禁止CPU中断,这两个写操作能够做到都被执行或者都不被执行,具有原子性,但是在多长CPU下,一个线程CPU-1,禁止了中断,但是不能保证其他核CPU-X执行该段代码。
同一时刻只有一个线程执行,我们称之为”互斥“。也就是说,如果我们能保证对共享变量的修改是互斥的,那么无论在但单核CPU还是多核CPU,就都能够保证原子性了。
互斥的解决方案,你肯定能够想到–锁。我们把一段需要互斥访问代码成为临界区。在进入临界区之前要进行加锁,访问临界区代码需要持有锁,当执行完临界区代码,则需要释放锁。如果想要执行临界区代码,但是没有锁,只能进行等待。
改进点在于,我们可以通过针对特定的受保护的资源创建特定的锁。这些很符合现实生活中的场景。
锁是一种通用解决方案,java语言为我们提供了synchronized关键字。
常见的使用方式如下:
class Hello{ public synchronized void world(){ ... } public static synchronized void doStatic(){ ... } public void workHard(){ Object o = new Object(); synchronized(o){ ... } } }
synchronized既可以声明在方法上,也可以使用在代码块中。Java编辑器将synchronized修饰代码中添加上 加锁和释放锁相关的代码。从而避免开发者出现错误(没有成对出现)
class SafetyCal{
long value = 0L;
long getValue(){
return value;
}
void synchronized addValue(){
value += 1;
}
}
通过synchronized关键字,保证了addValue的操作原子性。并且通过锁的Happens-before原则,保证了addValue操作对后续的addValue()操作都是可见的。从而保证了1000次addValue调用,其结果肯定是1000.
但是addValue的结果没有对针对getValue的可见性。如果想实现这种可见性,还得需要Happens-before原则,即:
class SafetyCal{
...
long synchronized getValue(){
return value;
}
...
}
其模型图,如下所示:
一个合理的关系是:受保护资源和锁时N:1
如果是多把锁保护同一个资源呢?
上图中 两个临界区之间没有互斥关系,因此会存在安全性问题。
互斥锁是并发中核心关注点,但存在并发话题,大家首先都想到加锁。但是,我们必须要深入分析锁对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量,才能用好互斥锁。
解决一个锁保护多个资源的问题
class Acount{ //余额锁 private Object baLock = new Object(); //余额 private Integer balance; //密码锁 private Object pwLock = new Object(); private String password; public void withDraw(Integer amt){ synchronized(baLock){ if(this.balance >= amt){ this.balance -= amt; } } } public Integer getBalance(){ synchronized(baLock){ return this.balance; } } public void updatePwd(String newPwd){ synchronized(pwLock){ this.password = newPwd; } } public String getPwd(){ synchronized(pwLock){ return this.password; } }
如上述代码中,我们分别针对密码(password)、余额(balance)使用了不同的锁。这种用不同的锁对资源进行精细化管理能够提升性能,这种锁称为细粒度锁
我们也可以使用一把锁同时保护 密码(password)、余额(balance),这是没有问题的,但是这样做,会使得密码相关操作与余额相关操作存在了互斥性,从而影响效率。
那么我们该如何用锁去保护相互关联的资源呢?我们还是以转账操作(账户A转给账户B100元)为例。
class Acount {
private Integer balance;
public void transfer(Acount target,Integer amt){
synchronized(this){
if(this.balance>=amt){
this.balance -=amt;
target.balance += amt; //这里是安全的吗?答案当然是否定的
}
}
}
}
在上述代码中,锁对象是要转账的对象。但是还对象不能保证target对象的准确性。
锁的正确使用姿势:它能够覆盖所有受保护的资源就可以了
根据该原则,4.2节中的正确代码应该是把Account.class对象当做锁即可
class Acount {
private Integer balance;
public void transfer(Acount target,Integer amt){
synchronized(Acount.class){
if(this.balance>=amt){
this.balance -=amt;
target.balance += amt; //这里是安全的,但是性能不会很理想,那该如何优化呢
}
}
}
}
针对如何保护多个资源。首先 要看这些资源是否有关系,如果没有关系,则每个资源一把锁即可;如果有关系,则要一个能够覆盖所有关联资源的锁。然后,还要梳理出有哪些访问路径,并且在访问路径上添加上合适的锁。
关联关系其实就是一种原子性特征。原子性的本质不是不可分割,而是多个资源间有一致性的要求,操作的中间状态对外不可见
一不小心死锁了,该如何处理?
在现实生活中,转账业务也是并行的,并不是4.3节那样使用Account.class对象作为锁对象,那样效率太差。
我们可以模拟将转账的操作模拟古代没有信息化的转账逻辑。
分成如下三种情况:
a. 转入账本或者转出账本都空闲,则可以直接进行转账操作
b. 只有转入(转出)账本,则需要等待另一个账本
c. 转入和转出账本都没有
class Account{
private int balance;
void transfer(Account target,int amt){
synchronized(this){
synchronized(target){
if(this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
细粒度锁可以提高并行度,是性能优化的一个重要手段。但是细粒度是有代价的,这个代价就是有可能导致死锁
死锁:一组相互竞争资源的线程因相互等待,导致‘永久’阻塞的现象
要想避免死锁,我们先来看一下产生死锁的必要条件:
class Allocator{ private List<Object> als = new ArrayList<Object>(); synchronized boolean apply(Object from, Object to){ if(als.containes(from)||als.contains(to)){ return false; } else { als.add(from); als.add(to); } return true; } synchronized void free(Object from,Object to){ als.remove(from); als.remove(to); } } class Account{ private int balance; void transfer(Account target,int amt){ //循环监听是是否两个账户可用 while(!actr,apply(this,target)); synchronized(this){ synchronized(target){ if(this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } }
当我们在编程世界遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,利用现实世界模型来构思解决方案,这样往往能够让我们的方案更容易理解,也能够看清问题的本质。
用等待唤醒机制优化循环等待?
在上节中,为了获取“两个账户都闲置 的时间点”,采用了死循环 while(!actr,apply(this,target)); 要知道通过死循环是非常消耗性能的
其实在这种场景下,最好的方案应该是:如果线程要求的条件不满足,则线程阻塞自己,进入等待状态;当线程的条件重新满足时,再进行唤醒操作。Java也是存在等待唤醒机制的。
一个完整的等待唤醒机制,首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当线程要求的条件满足时,通知等待线程,重新获取互斥锁
在Java语言中,等待通知机制实现方式可以有很多种,比如Java语言内置的synchronized配置wait、notify、notifyAll这三个方法就能轻松实现。
class Allocator{ private List<Object> als = new ArrayList<Object>(); synchronized void apply(Object from, Object to){ while(als.containes(from)||als.contains(to)){ try{ wait(); }catch(Exception e){ } } als.add(from); als.add(to); } synchronized void free(Object from,Object to){ als.remove(from); als.remove(to); notifyAll(); } }
**notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中所有的线程。**从感觉上notify应该会好一些,因为即便通知所有的线程,也只有有一个进入临界区。但是所谓的感觉往往都蕴藏着风险,实际上使用notify()也很有风险,它的风险在于可能导致某些线程用于不会被通知到。
假设有ABCD四个资源,线程1申请了AB两个资源,线程2申请了CD两个资源。线程3因无法申请AB而阻塞;线程4因无法申请CD而阻塞。当线程1释放了AB资源,进行notify操作,它有可能唤醒的是线程4,而线程四仍然会因为CD而阻塞。本该执行的线程3无法被唤醒。因此推荐使用notifyAll()
并发编程需要注意的问题有很多,主要分为三个方面:安全性、活跃性、性能问题
存在共享数据并且该数据会变化,通俗地讲就是有多个线程会同时读写同一数据
数据竞争:当多个线程访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发bug
竞态条件:程序的结果依赖于线程的执行顺序
面对数据竞争和竞态条件,保证线程安全的方法就是通过互斥。
活跃性问题除了死锁,还有活锁和饥饿
有时候线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是是“活锁”,解决活锁问题的就是 随机退让一个时间
线程因无法访问资源儿无法执行下去的情况。因线程优先级较低,永远无法执行到。 解决饥饿问题:1.保证资源充足 2.平均分配 3.避免持有锁的线程执行时间过长
a. 无锁方案
TLS 、Copy on write、乐观锁、JCP中原子类、Disruptor对列
b.减少锁持有的时间
细粒度锁、读写锁
性能相关的指标
* 吞吐量
* 延迟
* 并发量
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。