文章目录
一、并发编程要解决的问题
分工: 如何高效的拆分任务并分配给线程,直接决定了并发程序的性能。
同步: 主要指线程之间如何协作,即一个线程执行完一个任务,如何通知后续任务的线程开工。也可能存在,当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。在Java领域,解决协作问题的核心技术是管程,管程是解决并发问题的万能钥匙。
互斥: 互斥是保证线程安全的核心方案,互斥即保证同一时刻只允许一个线程访问共享资源。实现互斥的核心是锁。
这三个是并发编程的核心问题,Java SDK并发包大部分内容就是按照这三个维度组织的。
二、并发编程Bug的源头
1.缓存导致的可见性问题
可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到。
在单核时代,所有线程都操作的是同一个CPU的缓存,一个线程对缓存的写,对于另一个线程一定是可见的。
在多核时代,每颗CPU都有自己的缓存,当多个线程在不同CPU上执行时,这些线程操作的是不同的CPU缓存,此时线程对变量的变更不具备可见性(由于多核硬件架构的问题,cpu高速缓存之间本身是不可见的)。
2.线程切换带来的原子性问题
原子性: 一个或多个操作在CPU执行过程不被中断的特性,称为原子性。
CPU能保证的原子操作是CPU指令级的,一行高级语言可能会对应多条CPU指令,因此我们需要在高级语言层面保证操作的原子性。
操作系统允许某个进程执行一个时间片,之后操作系统就会重新选择一个进程来执行(我们称为“任务切换”)。Java并发程序都是基于多线程的,也会涉及到任务切换,这是并发编程Bug源头之一。
以count+=1
为例,至少需要三条CPU指令:
- 指令1:首先,需要把变量count从内存加载到CPU的寄存器;
- 指令2:之后,在寄存器中执行+1操作;
- 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
操作系统进行任务切换可以发生在任意CPU指令执行结束,如下图所示,线程A和线程B由不同的CPU核执行,最终结果是count=1
3.编译优化带来的有序性问题
有序性: 有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
这里的例子可以参考文章:单例设计模式
单例设计模式的懒汉实现方式中,加关键字volatile
修饰,就是为了防止JVM将new操作的顺序改变。
正常的new操作应该为:
- 分配一块内存M;
- 在内存M上初始化Singleton对象;
- 然后M的地址赋值给instance变量。
但是经过优化后,步骤2和3的顺序可能颠倒,此时可能会产生空指针异常,如下图:
三、源头问题的解决方案
1.利用Java内存模型解决可见性和有序性
java内存模型(JMM):规范了JVM如何按需禁用缓存和编译优化的方法。主要内容包括volatile、synchronized和final三个关键字以及六项Happens-Before规则。
(1)Happens-Before规则
Happens-Before:A happens-before B就是A先行发生于B,规则表达的是前一项工作的结果对后续工作可见,编译器的优化要遵守这个规则(并不严格限制重排序,如果执行顺序改变,但是结果一致,也是合法的优化)。
- 程序的顺序性规则: 在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
- volatile变量规则: 对一个volatile变量的写操作,相对于后续对这个变量的读操作可见,即,写Happens-Before读。JMM要求volatile写操作后,将工作内存(CPU缓存)中共享变量值刷新到主内存,对于volatile读操作,会将主内存中的最新值读到工作内存,再从工作内存中读取。
- 传递性规则: A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。(结合规则2,A、B、C不一定在一个线程中)
- 对管程锁的规则: 对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。(管程是通用的同步原语,synchronized是管程的实现)。JMM要求线程解锁前,要把共享变量的最新值刷新到主内存中,加锁时,清空工作内存(CPU缓存)中共享变量的值,使得使用共享变量需要去主内存中读取最新值。
- 线程启动规则: 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- 线程终止(join)规则: 主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作,即,如果在线程A中,调用线程B的 join() 并成功返回,那么线程B中的任意操作Happens-Before 于该 join() 操作的返回。
- 对象终结原则: 对一个对象的初始化完成,也就是构造函数的结束一定Happens-Before它的finalize()方法。
(2)使用Happens-Before规则解决并发问题
- 使用volatile保证共享变量的可见性(线程a执行wirter(),线程b执行reader())
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里 x 会是多少呢?
}
}
}
根据规则1有:“x=42” Happens-Before 写变量 “v=true”
根据规则2有:线程a的写变量“v=true” Happens-Before 线程b的读变量 “v=true”
根据规则3有:“x=42” Happens-Before 读变量 “v=true”
故综上,线程b的x读取的值必定为42。
使用synchronized保证共享变量的可见性(a线程对共享变量进行修改,如何让b线程知道)
note: 直接用volatile
对x
进行修饰也可以达到一样的效果,这里是为了综合使用三个规则而举例。
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁
根据规则4有:线程a在释放锁之后,线程b获取锁,此时可以看到线程a对共享变量x的修改,即x=12。
2.利用互斥锁解决原子性问题
(1)原子性问题的解决方案
原子性问题的源头是线程切换。如上文举的例子,在执行count+=1
的过程中发生了线程切换,导致多个线程执行时,结果可能会出错。因此,如果能禁止线程切换,那就可以解决这个问题,而线程切换是因为CPU中断,所以只要禁止CPU发生中断即可。
在单核CPU场景下,这种方案确实可行,但是在多核场景下并不能保证同一时刻只有一个线程在执行这段代码(临界区)。在多核CPU场景下,我们要保证在临界区,同一时刻只有一个线程在执行,即互斥,才可以保证原子性。
(2)synchronized关键字
Java中的synchronized关键字,是锁的一种实现,线程在进入临界区之前要加锁,出临界区要释放锁,如果加锁失败要等待,直到持有锁的进程释放锁再进入,这样就可以保证了原子性。
synchronized关键字可以修饰方法或者代码块:
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
note:
- 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
- 当修饰非静态方法的时候,锁定的是当前实例对象 this。
- 当修饰代码块的时候,锁定的是当前实例对象的指定区域,比修饰方法表现更高效。
(3)用synchronized关键字解决原子性问题
以上文提到的count+=1
为例:
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
由Happens-before中的传递性规则和管程锁的规则可知,一个线程在临界区做出的动作,对于后续进入临界区的线程是可见的。此时不管有多少个线程来执行这个方法,最后的结果也是一致的。
如图所示,我们要明确,这个类中由锁保护的是共享资源value,我们的get()方法和addOne()方法都要访问value这个共享资源,只有获得锁的方法才可以访问,因此这两个方法也是互斥的。
note:
- 这里对get()方法也用synchronized关键词修饰,是为了保证变量的可见性,这里不需要用考虑原子性问题,因为get()方法有原子性 。
- 在本例中,synchronized修饰的是非静态方法,因此锁的是当前实例。
(4)锁和受保护的资源的关系
受保护资源与锁的关系是N:1,也就是说我们可以用一把锁来保护多个资源,但是一个资源最多只能有一把锁来保护。
举例:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
此时共享资源value有两把锁来保护,分别是SafeCalc.class和当前实例this,此时get()和addOne()两个方法竞争的锁不一样,因此没有互斥关系,会导致并发问题。
(5)使用锁保护多个有关联的资源
若要保护多个没有关联的资源,只需要多个锁即可,但是如果要保护多个有关联的资源,应该如何应对呢?
举例: 银行业务里面的转账操作,假设有 A、B、C 三个账户,余额都是 200 元,我们用两个线程分别执行两个转账操作:账户 A 转给账户 B 100 元,账户 B 转给账户 C 100 元。我们不同的账户实例都是有关联的。
方案1:
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
上述方案中,我们对非静态方法transfer()加锁,锁的对象是当前实例,试图保护this和target两个实例的balance属性,这是不可取的,因为此时我们在target对象中,也可以获取到锁,对balance进行修改。
案例分析: 我们假设线程 1 执行账户 A 转账户 B 的操作,线程 2 执行账户 B 转账户 C 的操作。这两个线程分别在两颗 CPU 上同时执行,因为线程 1 锁定的是账户 A 的实例(A.this),而线程 2 锁定的是账户 B 的实例(B.this),所以这两个线程可以同时进入临界区 transfer(),导致最终转账结果错误。(先对balance进行写操作的线程对balance的修改被后一个线程的初始值覆盖)
方案2:
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
上述方案中,由于Account.class被所有Account的实例所共享,因此选用它作为锁,这样确实可以保证多个转账操作同时顺利进行,但是会导致所有转账操作变成串行,非常影响性能。
十、参考资料
https://blog.csdn.net/lepaitianshi/article/details/92802669
https://blog.csdn.net/seanxwq/article/details/118090738
https://www.cnblogs.com/wadmwz/p/10504164.html
https://zhuanlan.zhihu.com/p/133768732
https://hollis.blog.csdn.net/article/details/113362415
https://blog.csdn.net/jarwis/article/details/82669117
https://blog.csdn.net/weixin_42131352/article/details/112923121
https://ifeve.com/java%E9%94%81%E6%98%AF%E5%A6%82%E4%BD%95%E4%BF%9D%E8%AF%81%E6%95%B0%E6%8D%AE%E5%8F%AF%E8%A7%81%E6%80%A7%E7%9A%84/