在之前的文章中解释过了原子性,也就是一个或者多个操作在CPU执行的过程中不被中断的特性。前一篇文章中我们说了应该如何解决有序性和可见性的问题,就是借助,volatile,final,sync这种关键字,或者Happens-Before的六大原则来解决。
这篇文章也就顺理成章的来介绍一下如何解决原子性的问题。
原子性的问题该如何解决
首先,原子性问题的源头是线程切换,如果能够禁用线程切换,就可以解决这个问题了。如果能够禁用线程切换那就能够解决这个问题。
但是操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。
如果CPU是单核的,那么这个方案就是可行的,但是如果多核情况下,以32位CPU上执行long型变量的写操作为例来说明这个问题,因为long型变量是64位,在32位CPU上执行写操作会被拆分成为两次写操作(写高32位和写低32位,如下图所示)
在单核CPU的情况下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就无法让线程切换了,这样的情况下,获得了CPU的使用权的线程可以不间断的执行,所以再多次的操作都是保证一致性的,要不就全部都被执行,要不就不执行。
但是在大多数情况下,我们都是有多核CPU的,所以有可能一个线程执行在CPU1,一个线程执行在CPU2,这个时候,禁止CPU中断,只能保证CPU上的线程连续执行,不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,就会出现bug。
“同一时刻只有一个线程执行” 这个条件是很重要的,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。
简易锁模型
我们让互相变量的修改套上锁,是一个比较容易想到的策略。说到锁,还会出现以下模型:
这是我们知道的简易锁的一般模型。这里的临界区,指的是我们需要互斥的代码。线程在进入临界区之前,首先尝试加锁,如果加锁成功,就进入临界区,这时候我们称这个线程持有锁,不然就等待,直到持有锁的线程解锁,持有锁的线程执行完临界区的代码后,执行解锁unlock()。
换句话说,临界区只有两种操作过程,一种是没被加锁,那么我执行的时候就加一下,以防别人和我抢;如果加锁了,我就等前面那个加锁的任务结束了,我再执行。
这种模型是一般比较简易的锁模型,但是容易让我们忽略两个问题,我们不知道自己锁的是什么,也不知道我们保护的是什么。
改进之后的锁模型
在java并发中,锁和他要保护的东西是要有一种对应的关系的,比如,A线程的资源,要用A锁要保护,而不是其他线程的锁,但是上面无法体现这个特点,所以改进了之前锁的模型。
首先,我们要把临界区的资源标记一下,其次,我们要保护临界区的资源R,就要创建LR(Lock R),最后,针对这把锁LR,我们还需要在进出临界区添加.lock和.unlock。另外,还要把R和LR建立一种关联,这个关联是很重要的,并且出了问题不好排查。
所以我们就要使用到sync了。
Synchronized
同步锁是一种通用的技术方案,Java语言提供的Synchronized关键字,就是锁的一种实现。Synchronized关键字可以用来修饰方法,也可以用来修饰代码块,它的使用实例如下:
class X {
// 修饰⾮静态⽅法
synchronized void foo() {
// 临界区
}
// 修饰静态⽅法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
而之前提到的lock和unlock操作是在编译的时候在sync的前后自动加上的,这样的好处是一定会成对的加锁和去锁。
但是在修饰静态和非静态方法的时候,锁定的对象还是有所区别的。
当修饰静态方法的时候,锁住的是当前类的class,上面的例子也就是class X
当修饰非静态方法的时候,锁住的是当前的实例对象this
当修饰一个obj对象的时候,锁住的就是obj对象的修改本身
如果把代码写的更加直白的话,
静态方法修饰相当于
synchronized(X.class) static void bar() {
// 临界区
}
非静态方法相当于
synchronized(this) void foo() {
// 临界区
}
用Synchronized解决count+=1的问题
前面文章举了一个count+=1的例子,可以看这篇文章https://blog.csdn.net/qq_41936805/article/details/99700977
现在我们可以用sync简单的解决这个问题,先看实例代码
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
既然我们使用了sync加互斥锁,那么就肯定能让我们保证原子性,但是是否能保证可见性呢?要回答这个问题就要先看一下上一篇专栏中提到的管程中锁的规则。
管程中锁的规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁
管程就是我们这里说的synchronized,我们知道sync修饰的临界区是互斥属性的,所以肯定能保证原子性,而所谓对一个锁的解锁Happens-Before于后续对这个锁的加锁意思则是前一个线程的解锁操作对后一个线程的加锁操作是可见的,综合之前说的Happens-Before的传递性原则,我们可以知道前一个线程在临界区修改的共享变量(该操作在解锁前)对后续进入临界区(该操作在加锁后)的线程是可见的。
所以,可见性是可以保证的。
但是上面的代码,我们只给value+=1加了锁,而getValue的操作没有加锁,是否能保证可见性呢?
当然是不可能的,根据管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁,所以不符合管程的规则,解决方法同上,用synchronized包裹起来。
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
根据我们前面的解释,现在我们不仅把value+=1这个操作互斥了,还把get和value+=1互斥了。
锁和受保护资源的关系
我们之前说到,锁与资源是存在对应关系的,那么关系是如何的呢?
一个合理的解释就是:受保护的资源和锁之间的关联关系是N:1的关系。
还用之前的案例来说:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
现在我们用两把锁锁住两个资源,之前也说过了,这两把锁分别锁的是this对象和SafeCalc这个类,因此锁住的两个临界区没有互斥的关系,所以会导致并发异常。
synchronized是Java在语言层面提供的互斥原语,其实Java里面还有很多其他类型的锁,但作为互斥锁,原 理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁/解锁, 就属于设计层面的事情了。