同步和锁
本系列文章主要讲解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 setB