一个或者多个操作在CPU执行的过程中不被中断的特性,称为“原子性”。注意,原子性是面向cpu指令级别操作的,而不是面向高级语言操作。
解决原子性问题
带来原子性问题的是线程切换,如果能够禁用线程切换,那就能够解决原子性问题。而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。这个方案,在单核CPU时代的确是可行的,而且也有很多应用案例,但是并不适合多核CPU场景。
在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能出现bug了。
“同一时刻只有一个线程执行”这个条件非常重要,称之为互斥。如果能够保证对共享变量的修改是互斥的,那么无论是单核CPU还是多核CPU,就能保证原子性了。
简易锁模型
说到互斥,一定会想到“锁”这个方案,如图:
一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时称这个线程持有锁;否则,就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。但是,存在两个极容易忽视的问题:锁的是什么?保护的又是什么?
改进后的锁模型
锁和锁要保护的资源是有对应关系的,上面的模型没有体现出它们之间的对应关系,故需要完善,如图:
首先,要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;其次,要保护资源R就得为它创建一把锁LR;最后,针对这把锁LR,需要在进出临界区时添加上加锁操作和解锁操作。另外,在锁LR和受保护资源之间的关联关系,如图中的连线,这个关联关系非常重要。
Java语言提供的锁技术:synchronized
锁是一种通用的技术方案,Java语言提供的synchronized关键字,就是锁的一种实现。synchronized关键字可以用来修饰方法,也可以用来修饰代码块,但是他没有显示的加锁lock()和解锁unlock()操作,这两个操作是被Java默默加上的,Java编译器会在synchronized修饰的方法或者代码块前后自动加上加锁lock()和解锁unlock(),这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的,毕竟忘记解锁unlock()可是一个致命的bug。
Java中有一条隐式规则:当synchronized修饰静态方法的时候,锁定的是当前类的Class对象;当synchronized修饰非静态方式的时候,锁定的是当前实例对象this;当synchronized修饰代码块的时候,锁定的是其后括号的对象。
锁和受保护资源的关系
一个合理的受保护资源和锁之间的关联关系是N:1。例如如下的代码:
class SafeCalc {
static long value = 0;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
如果仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。画一幅图来描述锁和受保护资源的关系:
从图中可知,由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性,这就导致并发问题了。所以在并发中,一定要注意受保护资源和锁之间的关联关系,是N:1的关系,反之,会导致并发问题。同时,在保护多个资源的时候,首先要区分这些资源是否存在关联关系。
保护没有关联关系的多个资源
对于那些没有关联关系的多个资源,用不同的锁来保护,解决并发问题。例如:银行业务中,有针对账户余额的取款操作,也有针对账户密码的更改操作,其中余额(balance)是一种资源,账户密码(password)也是一种资源,为它们分配不同的资源即可解决并发问题。
class Accout {
//保护余额的锁
private final Object balanceLock = new Object;
//账户余额
private Integer balance;
//保护账户密码的锁
private final Object passwordLock = new Object;
//账户密码
private String password;
//取款
void withdraw(Integer amt) {
synchronized(balanceLock) {
if(this.balance > amt) {
this.balance -= amt;
}
}
}
//查看余额
Integer getBalance() {
synchronized(balanceLock) {
return balance;
}
}
//更改密码
void updatePassword(String password) {
synchronized(passwordLock) {
this.password = password;
}
}
//查看密码
String getPassword() {
synchronized(passwordLock) {
return password;
}
}
}
其实,可以用一把锁来保护多个资源,例如用this这把锁来管理账户类所有的资源,但是用一把锁,性能会太差,所有的操作都是串行的。故用不同的锁对没有关联关系的资源进行精细化管理,能够提升性能,称之为细粒度锁。
保护有关联关系的多个资源
银行的转账业务中,涉及到两个存在关联关系的账户:转出账户A和转入账户B。要解决像这样多个资源是有关联关系的并发问题,有点复杂。先把问题代码化,代码如下:
class Account {
private int balance;
//转账
void transfer(Account target, int amt) {
if(this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
为了解决并发问题,可能会想到用synchronized关键字来修饰transfer()方法,代码如下:
class Account {
private int balance;
//转账
synchronized void transfer(Account target, int amt) {
if(this.balance > amt) {
this,balance -= amt;
target.balance += amt;
}
}
}
从上述代码中,在临界区内有两个资源,分别是转出账户的余额this.balance和转入账户的余额target.balance,用的是this这把锁,虽然满足了多个资源可以用一把锁来保护,但是还是有问题,问题就出在this这把锁上,this这把锁可以保护自己的余额this.balance,但是保护不了别人的余额target.balance。看示意图:
具体分析一下,假设有A、B、C三个账户,余额都是200元,用两个线程分别执行这两个转账操作:账户A转给账户B100元,账户B转给账户C100元,期望的结果是A的余额是100元,B的余额是200元,C的余额是300元。假设线程1执行账户A转账给账户B的操作,线程2执行账户B转账给账户C的操作。这个线程分别在两颗CPU上同时执行,但是它们不互斥,因为线程1锁定的是账户A的实例(A.this),而线程2锁定的是账户B的实例(B.this),所以这两个线程可以同时进入临界区transfet(),所以有可能线程1和线程2都会读到账户B的余额是为200,导致最终账户B的余额可能是300(线程1后于线程2写B.balance,线程2写的B.balance值被线程1覆盖),也可能是100(线程1先于线程2写B.balance,线程1写的B.balance值被线程2覆盖),就是不可能是200。
使用锁的正确姿势
锁和受保护的资源的关联关系是N:1,即可以用同一把锁来保护多个资源,代码上,只要使用的锁能够覆盖所有受保护资源就可以实现。实现的方案还是挺多的:
- 让所有的对象都持有一个唯一性的对象,但是这个方案缺乏实践的可行性,因为在创建对象的时候,必须要传入同一个锁对象,否则就会出现并发的问题。
- 使用类的class对象,类的class对象是所有类的实例共享的,而且class对象是Java虚拟机在加载类的时候创建的,不要担心其唯一性。
总结
对于如何保护多个资源,关键是要分析多个资源之间的关系。如果资源之间没有关系,每个资源对应一把锁即可。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。
原子性,其实不是不可分割,不可分割只是外在表现,其本质就是多个资源间有一致性的要求,操作的中间状态对外不可见。所以要解决原子性问题,是要保证中间状态对外不可见。