#同步和锁 本系列文章主要讲解Java并发相关的内容,包括同步、锁、信号量、阻塞队列、线程池等,整体思维导图如下:  本文主要讲解Java内存模型、同步块、重入锁`ReentrantLock`和读写锁`ReentrantReadWriteLock`的应用及源码实现。 我们之所以要用多线程并发,是为了更好地利用服务器资源,使程序的响应速度更好。然而,如果多个线程访问了相同的资源,如同一内存区的变量、数组和对象,以及外部数据库或者文件等,可能导致程序的运行结果和我们预期的不一致。 假设你家庭成员都用同一个银行账户,每天都有收入或者支出,我们来看如下的代码: package com.molyeo.java.concurrent; /** * Created by zhangkh on 2018/8/24. */ public class SynchronizedDemo { public static void main(String[] args){ Account account=new Account(); account.setBalance(100000); System.out.printf("Account : Initial Balance: %f\n",account.getBalance()); Spender spender=new Spender(account); Thread spenderThread=new Thread(spender); spenderThread.start(); Earner earner=new Earner(account); Thread earnerThread=new Thread(earner); earnerThread.start(); try { spenderThread.join(); earnerThread.join(); System.out.printf("Account : Final Balance: %f\n",account.getBalance()); } catch (InterruptedException e) { e.printStackTrace(); } } } `SynchronizedDemo`类先创建一个账户,然后将账户实例传递给`Spender`线程(每个月花钱的败家子)和`Earner`线程(挣钱养家的家庭成员),然后启动这两个线程。 其中`Account`的代码如下: public class Account{ private double balance; public double getBalance() { return balance; } public void setBalance(double balance) { this.balance = balance; } public void addAmount(double amount) { try{ Thread.sleep(10); }catch (Exception e){ e.printStackTrace(); } balance=balance+amount; } public void subtractAmount(double amount){ try{ Thread.sleep(10); }catch (Exception e){ e.printStackTrace(); } balance=balance-amount; } } `Account`拥有一个成员变量`balance`,用于记录账户余额,对外提供一个增加余额`addAmount()`和减少余额`subtractAmount()`的方法。 `Spender`和`Earner`代码如下: class Spender implements Runnable { private Account account; public Spender(Account account) { this.account=account; } @Override public void run() { for (int i=0; i<30; i++){ account.subtractAmount(1000); } } } class Earner implements Runnable{ private Account account; public Earner(Account account) { this.account=account; } @Override public void run() { for (int i=0; i<30; i++){ account.addAmount(1000); } } } `Spender`线程每个月花钱`30`次,每次花`1000`人民币;`Earner`线程挣钱`30`次,每次挣`1000`人民币。 `SynchronizedDemo`程序中先设置账户余额为`10`万元,可以认为是家庭存款,按照预期的结果,这个月败家子花了`3`万,养家的爸妈一起挣了`3`万,这个月后结算,家里存款还是`10`万。虽然爸妈这个月白忙了,但是败家子还是先别内疚了,先看看程序的输出。 第一次运行输出结果如下(由于有一定的随机性,结果不一定完全相同): Account : Initial Balance: 100000.000000 Account : Final Balance: 99000.000000 第二次运行输出结果如下(由于有一定的随机性,结果不一定完全相同): Account : Initial Balance: 100000.000000 Account : Final Balance: 112000.000000 程序到底怎么了呢,好好地每次运行结果还不一致了?要深入分析这个问题,我们要先了解下`Java`的内存模型。 ##Java内存模型 `Java`内存模型规范了`Java`虚拟机和计算机内存的协同工作机制,简单的说就是说明了如何和何时可以看到由其他线程修改后的共享变量的值,以及在必须时如何同步地访问共享变量。 ###Java内存模型逻辑视图 `Java`内存模型把`Java`虚拟机划分为线程栈和堆。  每个运行在`Java`虚拟机中的线程拥有自己的线程栈,包含线程调用的方法当前执行点相关的信息。同时一个线程也仅仅能访问自己的线程栈,线程创建的本地变量仅自己可见,其他线程不可见。 原始类型(`boolean`, `byte`, `short`, `char`, `int`, `long`, `float`, `double`)的本地变量都存放在线程栈上,而`Java`程序中创建的对象都存在在堆上,不管是自定义的自定义对象,还是原始类型的包装对象(如`Byte`, `Integer`, `Long`等)。下面这张图演示了调用栈和本地变量存放在线程栈上,对象存放在堆上。  变量到底是存放在线程栈上还是堆上,有如下几点说明: * 本地变量如果是原始类型,则始终存在线程栈上 * 本地变量如果是对象的引用,则这个引用存在现在线程栈上,而对象存在堆上 * 一个对象的方法中的本地变量存在线程栈上,即使这个对象存放在堆上 * 对象的成员变量和对象本身一起存放在堆上,不管成员变量是原始类型还是引用类型 * 静态成员变量也是存放在堆上 * 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个本地变量的私有拷贝。 ###硬件内存架构 现代硬件内存模型与`Java`内存模型有一些不同。理解内存模型架构以及`Java`内存模型如何与它协同工作也是非常重要的。 现代计算机硬件架构简单图示如下:  现代计算机通常由两个或者多个`CPU`组成,拥有寄存器,缓存,和主存,其中访问速度寄存器最高,缓存次之,再者就是主存。 通常情况下,当一个`CPU`需要读取主存时,它会将主存的部分读到`CPU`缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当`CPU`需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。 需要注意的是,当`CPU`需要在缓存层存放一些东西的时候,存放在缓存中的内容通常会被刷新回主存。`CPU`缓存可以在某一时刻将数据局部写到它的内存中,和在某一时刻局部刷新它的内存。它不会再某一时刻读/写整个缓存,通常是一个或者多个缓存行被读取或者写入。 硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内中。部分线程栈和堆可能有时候会出现在`CPU`缓存中和`CPU`内部的寄存器中。如下图所示:  当对象和变量被存放在计算机中各种不同的内存区域中时,就有可能会出现一些问题,主要包括两个方面: * 共享变量修改的可见性 * 多线程读写共享变量时出现竞态条件 我们以上面的`SynchronizedDemo`例子,从硬件内存架构来分析这两个问题。 **可见性** `Account`的成员变量`balance`被初始化为`100000`后,保存在主存中。`Spender`线程首次将这个共享对象读取到缓存后,调用`subtractAmount()`方法减少了`balance`的值。如果此时缓存数据没有被刷新到主存,则更改后的值对其他线程是不可见的。 而此时`Earner`线程也将共享对象拷贝到缓存中,注意此时的共享对象的值还是`100000`,然后调用`addAmount()`方法增加`balance`的值。这样两个线程多次更改后,导致最后`balance`的结果是不确定的,进而出现每次运行的结果可能不太一致。 为了解决共享变量的可见性问题,可以使用`Java`中的`volatile`关键字,其可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写到主存中去。 看到这里后,我们可以尝试对`Account`的成员变量`balance`使用`volatile`修饰,然后多次运行上述程序,看结果是否保持一致。 `Account`修改内容如下: private volatile double balance; 为了避免多次运行,我们在`SynchronizedDemo`的主程序中,直接循环`10`次: public class SynchronizedDemo { public static void main(String[] args){ for(int i=0;i<10;i++){ Account account = new Account(); account.setBalance(100000); System.out.printf("Account : Initial Balance: %s\n",account.getBalance()); Spender spender=new Spender(account); Thread spenderThread=new Thread(spender); spenderThread.start(); Earner earner=new Earner(account); Thread earnerThread=new Thread(earner); earnerThread.start(); try { spenderThread.join(); earnerThread.join(); System.out.printf("Account : Final Balance: %s\n",account.getBalance()); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("------------------------------------------------------"); } } } 让我们来见证奇迹的发生,看看程序的输出结果: Account : Initial Balance: 100000.0 Account : Final Balance: 105000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 99000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 99000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 100000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 99000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 98000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 96000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 94000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 97000.0 ------------------------------------------------------ Account : Initial Balance: 100000.0 Account : Final Balance: 98000.0 ------------------------------------------------------ 这是怎么回事呢,为什么每次运行的结果不完全一致呢,因为这里还涉及到操作原子性的问题。 **原子性** 所谓原子性,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 我们来看`Account`中用于修改余额`balance`的两个方法: public void addAmount(double amount) { try{ Thread.sleep(10); }catch (Exception e){ e.printStackTrace(); } balance=balance+amount; } public void subtractAmount(double amount){ try{ Thread.sleep(10); }catch (Exception e){ e.printStackTrace(); } balance=balance-amount; } 不管是`addAmount`还是`subtractAmount`方法对`balance`的操作都不具备原子性,其包括读取变量的原始值,进行加法或者减法的操作,然后写入工作内存。 假设`Account`初始值为`100000`,`Earner`线程先启动,读取了`balance`的原始值,还没有对`balance`进行修改前,这是线程被阻塞了。 然后`Spender`线程后启动,也读取了`balance`的值,由于该变量还没有被修改,故`Spender`线程的缓存还是有效的,然后进行减少`amount`(`amount`=`1000`)的操作,操作后`balance`的值为`99000`,将其写入工作内存,最后写入到主存。 然后`Earner`线程被激活了,虽然此时`Spender`线程对`balance`进行了修改,并使`Earner`线程中的缓存行无效,但由于`Earner`线程先前已经得到`balance`的值了,故`balance`的值还是先前的`100000`,继续进行相关的加法操作,操作后的最后`balance`值为`101000`,最后将结果写入到主存。此处涉及到`Java`语言的指令集架构,数据计算时基于栈的,有兴趣的可以去深入研究。 在`Java`中,只有基本数据类型的变量的读取和赋值是原子性操作的,即这些操作是不可中断的,要么执行,要么不执行。 如下对`long`型的`balance`赋值是原子性的: balance=100000 但对`balance`进行算术运算则不是原子性的,因为其包括读取`balance`的值、加`1000`、写入新的值等三个操作。 balance=balance+1000 如果我们要实现更大范围操作的原子性,则需要通过`synchronized`和`Lock`来实现。同步块或者锁可以保证同一个时刻仅有一个线程可以进入代码的临界区,同时代码块中所有被访问的变量将会从主存中读入,退出同步块时,不管这个变量本身是否被声明为`volatile`,所有被更新的变量会被刷新到主存。 有了这些理论知识后,我们再看上面的示例如何修改,才能是最终的运行结果正确且完全一致。 我们只需要将要在多线程中访问的方法`addAmount`和`subtractAmount`声明为`synchronized`即可。由于`synchronized`能够保证唯一访问和修改后的数据刷新到主存,则成员变量`balance`是否被声明为`volatile`都无所谓了。我们看修改后的`Account`: class Account { private double balance; public double getBalance() { return balance; } public void setBalance(double balance) { this.balance = balance; } public synchronized void addAmount(double amount) { try { Thread.sleep(10); } catch (Exception e) { e.printStackTrace(); } balance = balance + amount; } public synchronized void subtractAmount(double amount) { try { Thread.sleep(10); } catch (Exception e) { e.printStackTrace(); } balance = balance - amount; } } 然后不管我们运行多少次,都可以看到最后`balance`的值都是`100000`。 ##同步块 `Java`中同步块主要用来标记方法或者代码块时同步的,同步在同一个对象上的同步块同一时刻只能被一个进程进入并执行相关操作,其他线程被阻塞,直到执行该同步块的线程退出。 `Java`中主要要四种不同的同步块: * 实例方法 * 静态方法 * 实例方法中的同步块 * 静态方法中的同步块 ###实例方法同步 我们上面`Account`类中的两个方法就是实例方法上的同步(去掉线程睡眠的代码): public synchronized void addAmount(double amount) { balance = balance + amount; } public synchronized void subtractAmount(double amount) { balance = balance - amount; } `Java`实例方法同步是同步在拥有该方法的实例上的,只有一个线程能够在实例方法同步块中运行。如果有多个实例存在,则一个线程一次可以在一个实例同步块中执行操作,但是不同的线程针对不同的实例则是可以同时执行的。 ###实例方法中的同步 有时候不需要同步整个方法,而是方法中的一部分。我们将上面的`addAmount()`方法改写如下: public void addAmount(double amount) { synchronized (this){ balance = balance + amount; } } public synchronized void subtractAmount(double amount) { balance = balance - amount; } 在上例中,使用了关键字`this`,即为调用`addAmount`方法的实例本身。在同步构造器中用括号括起来的对象叫做监视器对象。上述代码使用监视器对象同步,同步实例方法使用调用方法本身的实例作为监视器对象。 如果实例方法同步和实例方法中的同步,是针对同一个实例的话,则同一时刻只有一个线程在这两个同步块中的任意一个方法内执行。 ###静态方法同步 静态方法的同步是同步在该方法所在的类对象上的,因为`JVM`中一个类只能对应一个类对象,故同一时刻只允许一个线程执行同一个类中的静态同步方法。 ###静态方法中的同步块 静态方法中的同步块和静态方法作用类似,都是同步在类对象上。 下面的两个静态方法的同步块是不允许同时被线程访问的。 public synchronized static void addAmount(double amount) { balance = balance + amount; } public static void subtractAmount(double amount) { synchronized (Account.class){ balance = balance - amount; } } ##线程安全 上面的章节中,我们深入探讨了线程同步以及同步块的多种形式,但是不是所有被多线程访问方法都需要同步呢?这就需要我们了解代码是到底是不是线程安全的。 所谓线程安全,是只允许被多个线程同时执行的代码,其不包含竞态条件。而多个线程同时更新共享资源时会引发竞态条件,因而需要知道`Java`线程执行时共享了什么资源。 ###局部变量 局部变量存储在线程栈中,即局部变量永远不会被多个线程共享,基础类型的局部变量是线程安全的。如下面的代码,即使多个线程调用,其结果总是返回固定值`1`。 public long fixedNumer(){ long result=0; result++; return result; } ###局部的对象引用 局部的对象引用和局部变量不一样,虽然引用自身存在线程栈上,但是引用所值得对象存在共享堆中。 如果这个方法中创建的对象不能逃逸出该方法,即实例不能被别的线程获取到,也不会被非局部变量引用到,则其是线程安全的,否则不是线程安全的。 在下面的代码中,我们在`makeAccountAndInit`方法中新建`Account`对象,并赋值给`account`引用。由于`account`没有被方法`makeAccountAndInit`返回,也没有被其他的方法返回,故这里的引用`account`是线程安全的。 public void makeAccountAndInit(){ Account account=new Account(); initBalance(account); } public void initBalance(Account account){ account.setBalance(100000); } 但是如果`account`作为结果被返回,则其可以被其他线程获取到,则不是线程安全的。 ###成员变量 成员变量存储在堆上,如果多个线程同时更新同一个对象的同一个成员,则代码不是线程安全的。就如同我们最开始的`SynchronizedDemo`例子。 至于如何判断我们的代码是否是线程安全的,可以根据线程控制逃逸规则。 ###线程控制逃逸规则 如果一个资源的创建、使用、销毁都是在同一个线程内完成的,且永远不会脱离该线程的控制,则该资源的使用就是线程安全的。 资源可以是对象,数组,文件,数据库连接,套接字等等。`Java`中你无需主动销毁对象,所以“销毁”指不再有引用指向对象。 此外即使对象本身是线程安全的,但对象中如果包含其他资源,也会导致应用不是线程安全的。 如`2`个线程都创建了各自的数据库连接,每个连接自身是线程安全的,但是如果连接到同一个数据库,并且更新同一行记录,如`update` `table` `set` `num`=`num`+`1` `where` `id`=`10000`;`。可能最后的结果并没有增加`2`,而是只增加`1`。 因而在实际编程时,一定要注意区分线程控制的对象是资源本身,还是只是资源的引用。 ##锁 `Java`中锁`Lock`和`synchronized`同步块一样,是一种线程同步机制,但提供了更灵活的结构,更细粒度的控制。 ###Lock和synchronized的区别 * `Lock`实现类提供了细粒度的控制。`synchronized`方法或者语句提供了对每个对象相关的隐式监视器锁的访问,但是强制锁的获取和释放均要出现在一个块结构中。而`Lock`实现类允许锁在不同的作用范围内获取和释放,并允许以任何的顺序获取和释放多个锁。 * `Lock`实现类提供了`synchronized`方法不包含的功能。如获取锁尝试、获取可中断的锁尝试、获得可超时锁的尝试 //仅在调用时锁为空闲状态才获取该锁。 boolean tryLock(); //如果当前线程未被中断,则获取锁。 void lockInterruptibly() throws InterruptedException; //如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁。 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; * `Lock`实现类提供了更丰富的语义。如保证排序、非重入用法或死锁检测等。 ###Lock使用示例 为了演示`Lock`的使用,我们使用`Lock`改写之前利用`synchronized`的`SynchronizedDemo`,新的程序代码如下: package com.molyeo.java.concurrent; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * Created by zhangkh on 2018/8/27. */ public class ReentrantLockDemo { public static void main(String[] args) { for (int i = 0; i < 10; i++) { AccountWithLock account = new AccountWithLock(); account.setBalance(100000); System.out.printf("Account : Initial Balance: %s\n", account.getBalance()); Spender spender = new Spender(account); Thread spenderThread = new Thread(spender); spenderThread.start(); Earner earner = new Earner(account); Thread earnerThread = new Thread(earner); earnerThread.start(); try { spenderThread.join(); earnerThread.join(); System.out.printf("Account : Final Balance: %s\n", account.getBalance()); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("------------------------------------------------------"); } } } class AccountWithLock extends Account { private final Lock lock = new ReentrantLock(); private double balance; public double getBalance() { return balance; } public void setBalance(double balance) { this.balance = balance; } public void addAmount(double amount) { try { lock.lock(); balance = balance + amount; } finally { lock.unlock(); } } public void subtractAmount(double amount) { try { lock.lock(); balance = balance - amount; } finally { lock.unlock(); } } } 只需要改写`Account`类,将`addAmount()`方法和`subtractAmount()`中采用获取锁,然后操作`balance`变量,最后释放锁即可。 由于`setBalance()`方法并没有在多个线程中使用,故此处没有改写,实际应用中需要注意。 可以看到程序中使用的是`Lock`的实现类`ReentrantLock`,即可重入锁。 ###Lock的类图 `Java`中主要有`Lock`和`ReadWriteLock`(读写锁)这两个接口,其中`Lock`的实现有可重入锁`ReentrantLock`、以及`ReentrantReadWriteLock`(可重入读写锁)的两个子类`ReadLock`和`WriteLock`. 而接口`ReaderWriteLock`实现只有`ReentrantReadWriteLock`(可重入读写锁)。类图如下:  ##可重入锁 在上面的示例中我们在类`AccountWithLock`中使用可重入锁`ReentrantLock`实现了类似同步块的功能。 可以说可重入锁是`java`.`util`.`concurrent`包的基石,众多`Java`并发工具类将其作为成员变量,以解决多线程的资源共享问题。  ###源码实现 通过查看`ReentrantLock`的源码,会知道其有三个子类。一个抽象子类`Sync`,以及`Sync`的的两个具体类`NonfairSync`、`FairSync`。而`Sync`是又继承了`AbstractQueuedSynchronizer`。整体类图如下: 