当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。
我们来看一个例子:
// 多线程 public class Main { public static void main(String[] args) throws Exception { var add = new AddThread(); var dec = new DecThread(); add.start(); dec.start(); add.join(); dec.join(); System.out.println(Counter.count); } } class Counter { public static int count = 0; } class AddThread extends Thread { public void run() { for (int i=0; i<10000; i++) { Counter.count += 1; } } } class DecThread extends Thread { public void run() { for (int i=0; i<10000; i++) { Counter.count -= 1; } } }
上面的代码很简单,两个线程同时对一个int
变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
例如,对于语句:
n = n + 1;
看上去是一行语句,实际上对应了3条指令:
ILOADIADD ISTORE
我们假设n
的值是100
,如果两个线程同时执行n = n + 1
,得到的结果很可能不是102
,而是101
,原因在于:
┌───────┐ ┌───────┐ │Thread1│ │Thread2│ └───┬───┘ └───┬───┘ │ │ │ILOAD (100) │ │ │ILOAD (100) │ │IADD │ │ISTORE (101) │IADD │ │ISTORE (101)│ ▼ ▼
如果线程1在执行ILOAD
后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD
后获取的值仍然是100
,最终结果被两个线程的ISTORE
写入后变成了101
,而不是期待的102
。
这说明多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待:
┌───────┐ ┌───────┐ │Thread1│ │Thread2│ └───┬───┘ └───┬───┘ │ │ │-- lock -- │ │ILOAD (100) │ │IADD │ │ISTORE (101) │ │-- unlock -- │ │ │-- lock -- │ │ILOAD (101) │ │IADD │ │ISTORE (102) │ │-- unlock -- ▼ ▼
通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
可见,保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized
关键字对一个对象进行加锁:
synchronized(lock) { n = n + 1;}
synchronized
保证了代码块在任意时刻最多只有一个线程能执行。我们把上面的代码用synchronized
改写如下:
// 多线程 public class Main { public static void main(String[] args) throws Exception { var add = new AddThread(); var dec = new DecThread(); add.start(); dec.start(); add.join(); dec.join(); System.out.println(Counter.count); } } class Counter { public static final Object lock = new Object(); public static int count = 0; } class AddThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock) { Counter.count += 1; } } } } class DecThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock) { Counter.count -= 1; } } } }
注意到代码:
synchronized(Counter.lock) { // 获取锁 ... } // 释放锁
它表示用Counter.lock
实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { ... }
代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized
语句块结束会自动释放锁。这样一来,对Counter.count
变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是0。
使用synchronized
解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized
代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized
会降低程序的执行效率。
我们来概括一下如何使用synchronized
:
找出修改共享变量的线程代码块;
选择一个共享实例作为锁;
使用
synchronized(lockObject) { ... }
。
在使用synchronized
的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized
结束处正确释放锁:
public void add(int m) { synchronized (obj) { if (m < 0) { throw new RuntimeException(); } this.value += m; } // 无论有无异常,都会在此释放锁}
我们再来看一个错误使用synchronized
的例子:
// 多线程 public class Main { public static void main(String[] args) throws Exception { var add = new AddThread(); var dec = new DecThread(); add.start(); dec.start(); add.join(); dec.join(); System.out.println(Counter.count); } } class Counter { public static final Object lock1 = new Object(); public static final Object lock2 = new Object(); public static int count = 0; } class AddThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock1) { Counter.count += 1; } } } } class DecThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock2) { Counter.count -= 1; } } } }
结果并不是0,这是因为两个线程各自的synchronized
锁住的不是同一个对象!这使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。
因此,使用synchronized
的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。
我们再看一个例子:
// 多线程 public class Main { public static void main(String[] args) throws Exception { var ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() }; for (var t : ts) { t.start(); } for (var t : ts) { t.join(); } System.out.println(Counter.studentCount); System.out.println(Counter.teacherCount); } } class Counter { public static final Object lock = new Object(); public static int studentCount = 0; public static int teacherCount = 0; } class AddStudentThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock) { Counter.studentCount += 1; } } } } class DecStudentThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock) { Counter.studentCount -= 1; } } } } class AddTeacherThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock) { Counter.teacherCount += 1; } } } } class DecTeacherThread extends Thread { public void run() { for (int i=0; i<10000; i++) { synchronized(Counter.lock) { Counter.teacherCount -= 1; } } } }