并发编程有3个源头性问题:缓存导致的可见性问题,编译优化导致的有序性问题,以及线程切换导致的原子性问题。解决可见性问题和有序性问题的方法是按需禁用缓存和编译优化,Java的内存模型就是一种按需禁用缓存和编译优化的规则,它规定了 JVM 如何提供相关的方法,这些已经在 Java内存模型与Hppens-Before规则 进行了描述。
我们把一个或者多个操作在 CPU 执行过程中不被中断的特性称为 原子性 。由于操作系统的时间片轮转机制,以及高级语言可能包含多个指令,导致一句高级语言在执行过程中可能出现线程切换。在并发编程中就会因为 线程切换导致原子性问题 。
锁模型是解决原子性问题的通用方案。线程在进入临界区之前必须持有锁,退出临界区时释放锁,此时其他线程就能再次获取锁。
锁与资源之间是 1:N 的关系,即一把锁可以保护多个资源。同时要注意不能用自己的锁保护别人的资源;要让代码实现互斥,必须使用同一把锁。
synchronized
关键字是 Java 语言对锁模型的实现,它可以修饰方法或者代码块,被修饰的方法和代码块会隐式地添加 lock()
和 unlock()
方法。
现代操作系统都是基于线程的分时调度系统,CPU会为线程分配 时间片 ,线程分配都时间片就获取到CPU的使用权。比如说线程 A 读取文件,它可以将自己标记为「休眠状态」,让出 CPU 的使用权。文件读取完成之后,操作系统再将其唤醒,线程 A 就有机会重新获得 CPU 的使用权。
线程切换为什么导致并发问题呢?Java 是一门高级语言,高级语言的一条语句往往包含多个 CPU 指定,比如说 count += 1
这条语句,至少包含 3 条 CPU 指令:
操作系统以指令为单位执行,期间伴随着线程切换。这就导致 count += 1
执行到一半,就有可能碰到线程切换,导致并发问题的产生,如下图所示:
我们把一个或者多个操作在 CPU 执行过程中不被中断的特性称为原子性,即我们期望 count += 1
在执行过程中是原子一样的,不可分割的整体,线程切换不会在执行这条语句相关的CPU指令时发生,但允许线程切换在 count += 1
执行之前或者之后发生。
锁模型是一种解决原子性问题的通用技术方案。在锁模型中, 临界区 是一段要互斥执行的代码,在进入临界区之前我们要执行 lock()
操作 持有锁 ,只有获取到锁的线程才能执行临界区的代码;执行完临界区代码执行 unlock()
操作 释放锁 ,此时其他线程就可以尝试获取锁。
在现实生活中,我们用锁来保护我们的东西,但不能用自己的锁来锁别人的东西。在锁模型中,锁与临界区中被保护的资源也有着关联关系,图中用箭头来表示它们之间的关联。
我们不能用一把锁来保护范围之外的资源,代码要实现互斥则要使用同一把锁。
锁是一种通用的技术方案,Java 语言提供的 synchronized
关键字,就是锁的一种实现。 synchronized
关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:
class X { // 修饰非静态方法 synchronized void foo() { // 临界区 } // 修饰静态方法 synchronized static void bar() { // 临界区 } // 修饰代码块 Object obj = new Object(); void baz() { synchronized(obj) { // 临界区 } } }
前面说过,锁模型中有锁以及它保护的资源,synchronized 修饰代码块的时候锁显然是 obj 对象, 那么 synchronized 修饰非静态方法和静态方法的时候,它创建的锁是什么呢?
Java 中有一条隐式规则:
当修饰静态方法的时候,锁定的是当前类的 Class 对象; 当修饰非静态方法的时候,锁定的是当前实例对象 this。
相当于
class X { // 修饰静态方法 synchronized(X.class) static void bar() { // 临界区 } } class X { // 修饰非静态方法 synchronized(this) void foo() { // 临界区 } }
锁可以保护一个或者多个资源。我们可以用一个范围较大的锁,比如说 X.class
保护多个相关的资源;也可以用不同的锁对被保护资源进行精细化管理,这就叫 细粒度锁 。
class SafeCalc { long value = 0L; long get() { return value; } synchronized void addOne() { value += 1; } }
这是一段想解决 count += 1
问题的代码,我们对 addOne()
使用 synchronized 加上互斥锁,可以保证其原子性。根据 Happens-before 管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,也可以保证其可见性。即使是 1000 个线程同时执行 addOne()
也可以保证 value 增加 1000。
但我们无法保证 get()
的可见性,管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。所以我们给 get()
也加上锁:
class SafeCalc { long value = 0L; synchronized long get() { return value; } synchronized void addOne() { value += 1; } }
此时 get()
和 addOne()
都持有 this 这把锁,此时 get()
和 addOne()
是互斥的,并且保证了可见性,缩模型如下图所示:
如果将 value 改为 static 的, addOne()
变为静态方法:
class SafeCalc { static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } }
此时 get()
和 addOne()
分别持有不同的锁, get()
和 addOne()
不互斥,也就不能保证可见性,就会导致并发问题。
现在要写一个银行转账的方法,用户 A 给用户 B 转账,将其转换成代码:
class Account { private int balance; // 转账 synchronized void transfer( Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }
用户 A 给用户 B 转账 100,要保证 A 的余额减少 100,B 的余额增加 100。由于转账操作可以是并发的,所以要保证转账操作没有并发问题。比如说 A 的余额只有 100,两个线程分别执行 A 给 B 转账 100,A 给 C 转账 100,这两个线程有可能同时从内存中读取到 A 的余额是 100,这就产生了并发问题。
解决这个问题的第一反应,就是给 transfer(Account target, int amt)
加上 synchronized。这样做真的对么? transfer()
此时有两个需要被保护的资源 target.balance
和 this.balance
即别人钱和自己的钱,但我们使用的锁是 this
锁,如下图所示:
自己的锁 this
能保护自己的 this.balance
但是无法保护别人的 target.balance
,就像我的锁不能即保护我家的东西,又保护你家的东西一样。
所以我们需要一把锁的范围更大一点,让它能够覆盖到所有的被保护资源,比如说传入同一个对象作为锁:
class Account { private Object lock; private int balance; private Account(); // 创建Account时传入同一个lock对象 public Account(Object lock) { this.lock = lock; } // 转账 void transfer(Account target, int amt){ // 此处检查所有对象共享的锁 synchronized(lock) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
或者使用类锁 Accout.class
,由于 Accoutn.class
是在 Java 虚拟机加载 Account 类时创建的,所以 Account.class
是所有 Account 对象共享且唯一的一把锁。
class Account { private int balance; // 转账 void transfer(Account target, int amt){ synchronized(Account.class) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
Accout.class
就可以同时保护两个不同对象的临界区资源: