什么是原子性?
一个或多个操作在CPU执行的过程中不被中断的特性称为原子性。
如何解决原子性问题?
原子性的问题根源是线程切换
,如果能够禁用线程切换就能解决问题。而操作系统做线程切换是依赖CPU中断
的,所以禁止CPU发生中断就能够禁止线程切换.
在早期单核时代禁用CPU中断
是可行的,但并不是适合多核场景。例如‘在32位的CPU上执行long型变量的读写操作,由于long是64位的,在32位CPU上执行写操作会拆分为两次写操作,即高32位、低32位
;
在单核CPU场景
下,同一时刻只有一个线程执行,禁止CPU中断
,意味着操作系统不会重新调度线程,也就是禁止了线程切换
,获得CPU使用权的线程就会不间断地执行,所以两次操作要么都执行,要么都不执行
。
但是在多核CPU场景
下,同一时刻无法保证只有一个线程执行,如果多个线程同时写long型变量高32位的花,就会出现诡异bug。
“同一时刻只有一个线程执行”我们称为互斥,如果能够保证共享变量的修改是互斥的,那么无论是单核CPU还是多核CPU,就都能保证原子性了。
锁
我们把一段需要互斥的代码块称为临界区
。线程在进入临界区之前,首先尝试加锁lock()
,如果成功,则进入临界区,此时我们称这个线程持有锁;否则就等待
其他持有锁的线程释放锁;持有锁的线程执行完临界区代码后,执行解锁unlock()
。
出现的问题:我们锁的是什么?我们保护的又是什么?临界区是单线程,如果执行耗时操作,程序性能就会大打折扣
锁和资源是有对应关系的,可以理解为不同的资源由它专门的锁来管理。
如下图中的资源R,那么就创建一个锁LR来保护R(箭头指向),这个关联关系非常重要。
锁是一种解决并发问题通用的技术方案,所以Java语言中提供了synchronized
关键字来实现锁。
synchronized
可以修饰普通方法、静态方法以及代码块。
public class Test {
/** 修饰普通方法 */
synchronized void m1() {
// 临界区
}
/** 修饰静态方法 */
synchronized static void m2() {
// 临界区
}
/** 修饰代码块 */
void m3() {
Object obj = new Object();
synchronized (obj) {
// 临界区
}
}
}
Java的隐式规则
为什么使用synchronized时并没有lock()和unlock()?
Java编译器会在synchronized修饰的方法或代码块前后自动加锁lock()和自动解锁unlock(),这样的好处是加锁和解锁一定是成对的,这就保证了加锁和解锁不对应而造成的死锁问题。
为什么synchronized修饰方法时没有对象?
当修饰普通方法的时候,锁定的是当前类的Class对象,在上面的例子中这个对象就是是Class Test;
当修饰静态方法时:锁定的是当前实例对象this。
修饰普通方法相当于
/** 修饰普通方法 */
synchronized(this) void m1() {
// 临界区
}
修饰静态方法
/** 修饰静态方法 */
synchronized(SynchronizedExample.class) static void m2() {
// 临界区
}
SafeCalu类下除Main方法外两个方法:一个普通方法get()和一个同步方法addOne(),一个共享变量count,get可以获取count的值,addOne是给count加1,那么是否会有并发问题呢?
示例代码:
public class SafeCalu {
long count;
long get() {
System.out.println("get->" + count);
return count;
}
synchronized void addOne() {
count += 1;
System.out.print("addOne->" + count + " ");
}
public static void main(String[] args) {
SafeCalu safeCalu = new SafeCalu();
// 多个线程执行addOne,随后执行get
new Thread(() -> {
safeCalu.addOne();
safeCalu.get();
}).start();
new Thread(() -> {
safeCalu.addOne();
safeCalu.get();
}).start();
new Thread(() -> {
safeCalu.addOne();
safeCalu.get();
}).start();
}
}
答案是会有并发问题。
先看addOne()方法是被synchronized修饰的,无论是单核还是多核,都能保证addOne是一个原子操作。
管程(在这指synchronized)中的锁的规则:对一个锁的解锁Happens-Before于后续对锁的加锁。
我们知道,synchronized修饰的临界区是互斥的,也就是同一时刻只能有一个线程能够执行临界区的代码;而所谓“对一个锁解锁Happens-Before后续对这个锁的加锁”,指的是前一个线程的解锁操作对下一个线程的加锁操作可见。综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前)对后面进入该临界区(该操作在加锁之后)的线程是可见的。
由上可得出如果有多个线程同时执行addOne()方法,可见性是可以保证的,也就是有1000个线程执行完addOne()方法,count最后的值也是1000。
但也许,你一不小心就忽视了 get() 方法。执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是 get() 方法也 synchronized 一下。
public class SafeCalu {
long count;
synchronized long get() {
System.out.println("get->" + count);
return count;
}
synchronized void addOne() {
count += 1;
System.out.print("addOne->" + count + " ");
}
...
上面的代码转换为我们提到的锁模型,就是下面图示这个样子。get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的。
这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是 Java 类里的方法,而门票就是用来保护资源的“锁”,Java 里的检票工作是由 synchronized 解决的。
一个合理的关系应该是:受保护资源和锁的关系是N:1的关系。
那如果是多个锁管理同一个资源是否会有并发问题呢?
按照上面的例子,将count置为静态变量,将addOne置为静态方法。
由于get()和addOne()方法都是同步方法,但是get()的锁对象是实例对象this,addOne()的锁对象是类对象SafeCalc,由于临界区是有不同的锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。
class SafeCalc {
static long count = 0L;
synchronized long get() {
// 临界区
return count;
}
synchronized static void addOne() {
// 临界区
count += 1;
}
}
虽然加锁能够保证执行临界区代码的互斥性,并能够解决并发问题,但是也不会随便用一把锁就会有效,所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
synchronized 是 Java 在语言层面提供的互斥原语,其实 Java 里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。