JAVA并发编程总结

1 理论基础

1.1缓并发问题Bug的源头

Cpu、内存、I/O设备在不断的迭代,但是在快速发展的过程中,有一个核心矛盾存在,就是这三者的速度差异。
为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都作出了贡献,主要体现为:
1.CPU增加了缓存,以均衡与内存的速度差异;
2.操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
但是这些也是并发程序很多诡异问题的根源。

1.1.1 缓存导致的可见性问题

在单核时代,所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说一定是可见的。例如在下面图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量V的值,那么线程B之后再访问变量V,得到的一定是V的最新值(线程A写过的值)。
在单核时代,所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说一定是可见的。例如在下面图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量V的值,那么线程B之后再访问变量V,得到的一定是V的最新值(线程A写过的值)。
在这里插入图片描述
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称之为可见性。
多核时代,每颗CPU都有自己的缓存,这是CPU缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。比如下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性。这个就属于硬件程序员给软件程序员挖的“坑”。
在这里插入图片描述
下面的一段代码验证了多核场景下的可见性问题。每执行一次add10K()方法,都会循环10000次count+=1操作。在calc()方法中创建了两个线程,每个线程调用一次add10K()方法,那么最终的结果是怎样的呢?

public class Demo1{
    private static long count = 0;
    private static void add10k(){
        int idx = 0;
        while(idx++ < 10000){
            count += 1;
        }
    }

    public static void calc(){
        final Demo1 demo1 = new Demo1();
        //创建两个线程,执行add()操作
        Thread th1 = new Thread(() -> {
            test.add10K();
        });

        Thread th2 = new Thread(() -> {
            test.add10K();
        });
        //启动两个线程
        th1.start();
    }
}

直觉告诉我们应该是20000,因为在单线程里调用两次add10K()方法,count的值就是20000,但是实际上calc()的执行结果是个10000到20000之间的随机数。为什么呢?
我们假设线程A和线程B同时开始执行,那么第一次都会将count=0读到各自的CPU缓存里,执行完count+1之后,各自缓存里的值都是1,同时写入内存后,我们会发现内存中是1,而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值,两个线程都是基于CPU缓存里的count值来计算,所以导致最终count的值都是小于20000的。这就是缓存的可见性问题。

1.1.2 线程切换带来的原子性问题

Java并发程序都是基于多线程的,因此会涉及到任务切换,但是任务切换是并发编程里Bug的源头之一。任务切换的时机大多数是在时间片结束的时候,JAVA是高级编程语言,高级编程语言里一条语句往往需要多条的CPU指令完成,例如上面代码中的count += 1,至少需要三条CPU指令。
指令1:首先,需要把变量count从内存加载到CPU的积存器;
指令2:之后,在寄存器中执行+1操作;
指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条CPU指令完,而不是高级语言里面的一条语句。对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完线程切换的,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们预期的2,而是1。
在这里插入图片描述
我们的潜意识里面觉得count+=1这个操作是一个不可分隔的整体,就像一个原子一样,但是CPU保证的原子操作是CPU指令级别的,因此我们需要在高级语言层面保证操作的原子性。

1.1.3 编译优化的有序性问题

有序性是指程序按照代码的先后顺序执行。编译器为了优化性能,有时会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响最终结果,不过有时候编译器的优化可能导致意想不到的bug。
在JAVA领域一个很经典的案例就是双重检查创建单例对象,例如下面的代码;在获取实例getInstance()的方法中,我们首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance是否为空,如果还为空则创建Singleton的一个实例。

public class Singleton {
    private Singleton(){
    }

    //单例对象. volatile + 双重检测机制 -->禁止指令重排
    private static Singleton instance = null;

    //静态工厂方法
    public  static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    //1. memory= allocate() 分配对象内存空间
                    //2. ctorInstance() 初始化对象
                    //3. instance = memory 设置instance  指向刚分配的内存
                    //指令重排
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

假设有两个线程A、B同时调用getInstance()方法,他们同时发现instance==null,于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功(假设线程A),另外一个线程则会处于等待状态(假设是线程B);线程A会创建一个Singleton实例,之后释放锁,释放锁之后,线程B被唤醒,线程B再次尝试加锁,此时是可以被成功加锁的,加锁成功之后,线程B检查instance == null时会发现,已经创建过Singleton实例了,所以线程B不会创建一个Singleton实例。
但是在new Singleton()时会出现问题,我们以为new操作应该是:
1.分配一块内存;
2.在内存M上初始化Singleton对象
3.然后M的地址赋值给instance变量
但是实际上优化后执行路径可能是2和3颠倒的。优化后会导致什么问题呢?我们先假设A先执行了getInstance()方法,那么线程B在执行第一个判断时会发现instance != null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量可能触发空指针异常。

1.2JAVA内存模型(解决可见性和有序性问题)

1.2.1 什么是JAVA内存模型

从上小节已经知道导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性的最直接的办法就是禁用缓存和编译优化,但是如果这样做程序的性能会受到很大的影响。所以合理的方案应该是按需禁用缓存以及编译优化。
JAVA内存模型是个很复杂的规范,可以从不同的角度来解读,站在程序员的角度,本质上可以理解为为,JAVA内存模型规范了JVM如何提供按需禁用和编译的方法,具体来说,这些方法包括volatile、synchronized和final 三个关键字。
大家对volatile、synchronized比较熟悉,但是final常常被忽略。final修改的变量时,初衷是告诉编译器:这个变量生而不变,可以随意优化。

1.2.2 Happens-Before原则

Happens-Before是指前面的一个操作的结果对于后续的操作是可见的。Happens-Before 约束了编译器的优化行为,虽允许编译器的优化,但是编译器一定要遵守Happens-Before原则。

1.2.2.1程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的的任意操作。

1.2.2.2 volatile变量

这条规则是指对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。

1.2.2.3 传递性

这条规则是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
举个列子说明上面三个规则,如下面的代码:

class VolatileExamole{
	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”,这是规则1 的内容;
2.写变量“v=true” Happens-Before 读变量“v=true”,这是规则2的内容;
3.最终得到结果“x=42”,即线程A设置的“x=42”对线程B是可见的,这是规则3的内容。

1.2.2.4 管程中的锁规则

这条规则是指对一个锁的解锁Happens-Before 于后续对这个锁的加锁。管程是一个同步原语,在JAVA中指的是synchronized,synchronized是java对管程的实现。

Synchronized(this){//此处自动加锁
   //x 是共享变量,初始值=10
   If(this.x < 12){
     this.x = 12; 
}
} //此处自动解锁

假设x的初始值为10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。

1.2.2.5 线程start()原则

这条是关于线程启动的。它是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。

Thread B = new Thread(()->{
  //主线程调用B.start()之前
  //所有对共享变量的修改,此处皆可可见
  //此例中 var=77
}
//此处对共享变量var修改
var = 77//主线程启动子线程
B.start()
1.2.2.6 线程的join原则

这条规则是关于线程等待的。它是指主线程A等待子线程B的完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成时(主线程A中join()方法返回,主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
换句话就是,如果在线程A中,调用线程的join()并成功返回,那么线程B中的任意操作Happens-Before于改join()操作的返回。

Thread B = new Thread(()->{
  //此处对共享变量var修改
var = 66;
});

//如果这边对共享变量修改,
//则这个修改结果对线程可见
//主线程启动子线程
B.start();
B.join()
//子线程所有对共享变量的修改
//在主线程调用B.join()之后可见
//这里var=66

1.2.3 final

final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以随意优化。
但是如果使用错误,容易导致“逸出”。
举个例子,在下面的构造函数里面this赋值给了全局的变量global,obj。这就是“逸出”,线程通过global.obj读取x是可能读到0的,因此我们一定要避免。

final int x;
//错误的构造函数
public FinalFiledExample(){
	x = 3;
	y = 4;
	//此处就是this.逸出
	global.obj = this;
}

1.3互斥锁(解决原子性问题)

原子性问题的源头就是线程切换,如果能够禁用线程切换就能解决这个问题。而操作系统做线程的切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。
在早期单核CPU时代,这个方案是可行的,而且也有很多应用案例,但是并不适合多核场景。这里我们以32位CPU上执行long类型变量的写操作来说明这个问题,long型变量是64位,在32位CPU上执行写操作会被拆分为两次写操作(写高32位和写第32位,如下图所示)。
在这里插入图片描述
在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程的切换,获得CPU使用权的线程就可以不间断地执行 ,所以两次写操作具有原子性。
但是多核的场景下,同一时刻,有可能两个线程同时在执行,一个线程在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量的高32位的话,那就有可能出问题。
所以“同一时刻只有一个线程执行”,这个条件很重要,称之为“互斥”。如果我们能够保证对共享变量的修改是互斥的,俺么,无论单核CPU还是多核CPU,都能保证原子性。

1.3.1简易锁模型

互斥一般使用锁来解决。锁的模型如下:
在这里插入图片描述
我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。那我们锁的是什么?保护的是什么呢?

1.3.2改进后的锁模型

在并发编程的世界里,锁和资源有对应关系。如下图所示:
在这里插入图片描述
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加一个元素:受保护的资源R;其次,我们要保护资源R就得为它创建一把锁LR;最后,针对这把锁LR,我们还需要在进出临界区时添加上加锁操作和解锁操作。另外,在锁LR和受保护资源之间是有关联的。

1.3.3Java中提供的锁技术synchronized

锁是一种通用的解决方案,Java语言提供的Synchronized关键字,就是锁的一种实现。Synchronized关键字可以用来修饰方法,也可以用来修饰代码块。它的使用方法如下:

class X{
	//修饰非静态方法
	synchronized void foo(){
		//临界区
	}
	//修饰静态方法
	sychronized static void bar(){
		//临界区
	}
	//修改代码块
	Object obj = new Object();
	void bar2(){
		sychronized(obj){
			//临界区
		}
	}
}

其中加锁和解锁的操作都是被java默默加上的,Java编译器会在Synchronized修饰的方法或者代码块前后自动加上加锁lock()和解锁unlock(),这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的。
那么Synchronized里的加锁lock()和解锁unlock()锁定的对象在那里呢?上面的代码看到只有修饰代码块的时候,锁定了一个obj对象,那么修饰方法的时候锁定的是什么呢?这个也是Java的一条隐式规则:
当修饰静态方法的时候,锁定的是当前类的Class对象,在上面的例子中就是Class X;
当修饰非静态方法的时候,锁定的是当前实例对象的this。
对于上面的例子,synchronized修饰静态方法相当于:

class X {
  // 修饰静态⽅法
  synchronized(X.class) static void bar() {
  // 临界区
 }
}
修饰非静态的方法,相当于:
class X {
  // 修饰非静态⽅法
  synchronized(X.this) void foo() {
  // 临界区
 }
}

用synchronized解决count+=1问题,代码如下所示。SafeCalc这个类有两个方法,一个是get,一个是addOne()方法。

class SafeCalc{
	long value = 0L;
	long get(){
	  return value;
	}
	sychronized void addOne(){
		value += 1;
	}
}

被synchronized修饰后,无论是单核CPU和多核CPU,只有一个线程能够执行addOne()方法,所以可以保证原子性。根据管程中锁的规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁,所谓“对一个锁的解锁Happens-Before后续对这个锁的加锁”。指的是前一个线程的解锁操作对后一个线程的加锁操作可见。综合Happens-Before的传递性的原则,我们就能得到一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。按照这个规则,如果多线程同时执行addOne()方法,可见性是可以保证的。
但也许,你一不小心就忽视了get()方法,执行addOne()方法后,value的值对get()方法可见么,显然是不可以的。管程周明华锁的原则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。如何解决呢,使用synchronized修饰get()方法就好。

class SafeCalc{
	long value = 0L;
	synchronized long get(){
	  return value;
	}
	sychronized void addOne(){
		value += 1;
	}
}

1.3.4锁和受保护资源之间的关系

一个合理的关系是:受保护资源和锁之间的关联关系是N:1的关系。上面的例子稍作改动,把value改成静态变量,把addOne()方法改成静态方法,此时get()方法和addOne()方法是否还存在并发问题。

class SafeCalc{
	static long value = 0L;
	synchronized long get(){
	  return value;
	}
	sychronized static void addOne(){
		value += 1;
	}
}

仔细分析后,发现改动后的代码用两个锁保护了一个资源就是静态变量value,两个锁分别是this和SafeCalc.class。我们可以用下面的图来形象描述这个关系。由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性。
在这里插入图片描述

1.3.5如何用一把锁保护多个资源

上小节中,我们提到受保护资源和锁之间合理的关联关系是N:1的关系,也就是说用一把锁来保护多个资源。

1.3.5.1 保护没有关联关系的多个资源

这种场景非常容易解决,各自管各自的。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对张哈密码的(密码是一种资源)的更改操作,我们可以为账户余额分配不同的锁来解决并发问题,这个是非常简单的。
相关代码示例如下,账户类的Account有两个成员变量,分别是账户余额balance和账户密码password。取款的withdraw()和查询余额的getBalance()操作会访问账户余额balance,我们创建一个final对象的baLock作为锁;而修改密码updatePassword()和查看密码getPassword()操作会修改账户密码password,我们创建一个final对象pwLock作为锁。不同的资源用不同的锁保护,各自管各自的。

class Account{
	//锁:保护账户余额
	private final Object balLock
	 	= new Object();

	 //账户余额
	private Integer balance;

    //锁:保护密码
	private final Object pwLock;

	//账户密码
	private String password;

	//取款
	void withdraw(Integer amt){
		synchronized(balLock){
			if(this.balance > amt){
			  this.balamce -= amt;
			}
		}
	}

	//查询余额
	Integer getBalance(){
		synchronized(balLock){
			return balacne;
		}
	}

	//更改密码
	void updatePassword(String pw){
    	synchronized(pwLock){
    	  this.password = pw;
    	}
	}

    //查看密码
	String getPassword(){
	  sychronized(pwLock){
	    return password;
	  }
	}
}

我们也可以使用一把锁来保护多个资源。但是使用一把锁有个问题,就是性能太差了,会导致取款,查看余额。修改密码。查看密码着四个操作都是串行的。而我们用两把锁,取款和修改密码都是并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。

1.3.5.2 保护有关联关系的多个资源

如果多个资源是有关联关系的,那这个问题就有点复杂。例如银行业务里面的转账操作,账户A减少100元,账户B增加100元。这两个账户就是关联关系的。那对于像转账这种有关联的操作,我们应该怎么去解决呢?如下面的代码,又何问题呢?

class Account{
	private int balance;
	//转账
	void transfer(Account target,int amt){
		if(this.balance > amt){
			this.balance -= amt;
			target.balance += amt;
		}
	}
}

上面的操作存在问题,操作不是一个原子。用Synchronized 关键字修饰一下transfer()方法就可以了。代码如下:

class Account{
	private int balance;
	//转账
	sychroniezd void transfer(Account target,int amt){
		if(this.balance > amt){
			this.balance -= amt;
			target.balance += amt;
		}
	}
}

在这段代码中,临界区有两个资源,分别是转出账户的余额this.balance和转入账户的余额target.balance,并且用的是一把锁this,符合我们前面提到的,多个资源可以用一把锁来保护。
看上去完全正确,但是问题出在this上,this这把锁可以保护自己余额this.balance,却保护不了别人的余额target.balance。
假设有A、B、C三个账户,余额都是200元,我们两个线程分别执行两个转账操作:账户A转给账户B 100元,账户B转给账户C 100元,最后我们期望的结果应该账户A的余额是100元,账户B的余额是200元,账户C 的余额是300元。
假设线程1执行账户A转账户B的操作,线程2执行账户B转账户C的操作。这两个线程分别在两个CPU上同时执行,他们是不互斥的。因为线程1锁定的是账户A的实例(A.this),而线程2锁定的是账户的实例(B.this),所以这个这两个线程可以同时进入临界区transfer()。同时进入临界区的结果是什么?线程1和线程2都会读到账户B的余额为200,导致最终账户B的余额可能是300(线程1后于线程2写B.balance。线程2写的是B.balance值被线程1覆盖了),可能是100(线程1优先线程2写B.balance)。
解决这个问题的方式很简单,直接使用同一把锁来保护多个资源。实例代码如下,我们把Account默认的构造函数变为private,同时增加一个带Object lock参数的构造函数,创建Account对象时,传入相同的lock,这样所有Account对象都会共享这个lock了。

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){
	  //此处检查所有对象共享的锁
	  sychronized(lock){
	    if(this.balance > amt){
	       this.balance -= amt;
	       target.balance += amt;
	    }
	  }
	}
}

这个方法,需要传入同一个对象,但是在实际项目中,创建Account对象的代码可能被分散在多个工程中,传入共享的lock真的很难。
所以,上面的方案缺乏实践的可行性,我们需要更好的方案。使用Account.class所谓共享锁。这样代码更简单。

class Account{
	private int balance;
	void transfer(Account target,int amt){
		synchronized(Account.class){
		  if(this.balance > amt){
		    this.balance -= amt;
		    target.balance += amt;
		  }
		}
	}
}

“原子性”的本地是多个资源间有一致性的要求,操作的中间件状态对外不可见,所以解决原子性问题,是要保证中间状态对外不可见。

1.4线程死锁

1.4.1产生死锁

在上一节中,我们使用Account.class作为互斥锁,来解决银行业务里面的转账问题,虽然方案不存在方案问题,但是所有的账户操作都是串行的,例如账户A转账户B,账户C转账户D这两个操作现实中是可以并行的,但是在这个方案中却被串行化了,这样的话,性能太差。
在现实世界中,账户的转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。
试想在古代,没有信息化,账户的存在的形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一放在文件架上。银行柜员在给我们做转账的时,要去文件架上把转出的账本和转入的账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:
1.文件架上恰好有转出账本和转入账本,那就同时拿走;
2.如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
3.转出账本和转入账本都没有,那这个柜员就等着两个账本被送回。
上面这个过程在编程世界里怎么实现呢?其实用两把锁就可以了,转出账本一把,转入账本一把。在transfer()方法内部,我们尝试锁定转出账户this(先把转出账本拿到手),然后尝试锁定转入账户target(再把转入账本拿到手),只有两者都成功时,才执行转账操作。这个逻辑可以图形化为下图。
在这里插入图片描述
详细的代码优化如下:

class Account{
	private int balance;
	//转账
	void transfer(Account target,int amt){
		//锁定转出用户
		sychronized(this){
			//锁定转入账户
			sychronized(target){
				if(this.balance > amt){
					this.balance -= amt;
					target.balance += amt;
				}
			}
		}
	}
}

上面的实现看上去很完美,并且也算是将锁用的出神入化了。相对于用Account.class作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小很多了,这样的锁,叫做细粒度锁。使用细粒度锁可以提高并行度,是性能优化的一个重要手段。但是使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

1.4.2预防死锁

并发程序一旦死锁,一般没有特别好的方法,很多时候只能重启应用。因此,解决死锁问题最好的方法还是规避死锁。
首先得知道什么时候出现死锁,下面四个条件发生时才会出现死锁:
1.互斥,共享资源X和Y只能被一个线程占用;
2.占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源Y;
3.不可抢占,其他资源不能抢占线程T1占有的资源;
4.循环等待,线程T1等待线程T2占有的资源,线程T2等待T1占有的资源,就是循环等待。
反过来分析,也就是说,我们只要破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没法破坏,因为我们用锁就是为了互斥。不过其他的三个条件都是有办法破坏掉的。
1.对于“占用且等待”这个条件,我们一次性申请所有的资源,这样就不用等待了。
2.对于“不可抢占“这个条件,占用部分资源的线程进一步申请其他资源的时候,如果申请不到,可以主动释放它占有的资源,这样不可抢占的这个条件就破坏掉了。
3.对于“循环等待”这个条件,可以按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源小的,再申请资源大的,这样线性化后自然就不存在循环了。

1.4.3破坏占用且等待条件

从理论上讲,要破坏这个条件,可以一次性申请所有的资源。在现实世界里,就拿前面我们提到的转账操作来说,它需要两个资源,一个是转出账户,一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?
可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说官员不能直接从文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本A和B,账本管理员如果发现文件架上只有账本A,这个时候账本管理员是不会把账本A拿下来给张三的,只有账本A和B都在的时候才会给张三。这样就保证了“一次性申请所有的资源”。
对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(java里面的类)来管理这个临界区,我们把这个角色定义为Allocator。他有两个重要的功能,分别是:同时申请资源apply()和同时释放资源free()。账户Account类里面持有一个Allocator的单利(必须单例,只能由一个人来分配资源)。当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知Allocator同时释放转出账户和转入账户这两个资源。

class Allocator{
	private List<Object> als = new ArrayList<>;
	//一次性申请所有资源
	sychronized boolean apply(Object from,Object to){
		if(als.cotains(from) || als.contains(to)){
			return false;
		}else{
			als.add(from);
			als.add(to);
		}
		return true;
	}

	//归还资源
	sychronized void free(Object from ,Object to){
		als.remove(from);
		als.remove(to);
	}
}

class Acount{
	//actr 应该是单例
	private Allocator actr;
	private int balance;
	//转账
	void transfer(Account target,int amt){
		//一次性申请转出账户和转入账户,直到成功
		while(!actr.apply(this,target));
		try{
			//锁定转出账户
			sychronized(this){
				//锁定转入账户
				sychronized(target){
					if(this.balance > amt){
						this.balance -= amt;
						target.balance -= amt;
					}
				}
			}
		}finally{
			actr.free(this.target);
		}
	}
}

1.4.4破坏不可抢占条件

破坏不可抢占条件,这一条synchronized是做不到的。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了已经占用的资源。在java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。

1.4.5破坏循环等待条件

破坏循环等待条件,需要对资源进行排序,然后按序申请资源。这个实现实现很简单,我们假设每个账户都有不同的属性ID,这ID可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,1~6处的代码对转账(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户,这样就不存在循环等待了。

class Account{
	private int id;
	private int balance;
	//转账
	void transfer(Account target,int amt){
		Account left = this;
		Account right = target;
		if(this.id > target.id){
			left = target;
			right = this;
		}
		//锁定序号小的账户
		sychronized(left){
			sychronized(right){
				if(this.balance > amt){
					this.balance -= amt;
					target.balance += amt;
				}
			}
		}
	}
}

1.5用“等待-通知”机制优化循环等待

在破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文本架上这个条件,就用死循环的方式来循环等待。
//一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this,target));
如果apply()操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的,因为这种场景下,循环上几十次或者几十次就能一次性获取转出账户和转入账户了。但是如果apply()操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,因为在这种场景下,可能要循环上万次才能获取到锁,太消耗CPU了。
其实在这种场景下,最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文本架上)满足后,通知等待的线程重新执行的。其中,使用线程阻塞的方式就能避免循环等待消耗CPU的问题。

1.5.1什么是“等待-通知”

以一个例子说明,现实世界的就医流程,有着完善的等待-通知机制。
就医流程基本上是这样:
1.患者先去挂号,然后到旧镇门口分诊,等待叫号;
2.当叫到自己的号时,患者就可以找大夫就诊了;
3.就诊过程中,大夫可能会让患者取做检查,同时叫下一位患者;
4.当患者做完检查后,拿检测报告重新分诊,等待叫号;
5.当大夫再次叫到自己的号时,患者再去找大夫就诊。
下面我们来对比看一下前面忽视了哪些细节。
1.患者到就诊门口分诊,类似于线程去获取互斥锁;当患者被叫到时,类似线程已经获取到锁。
2.大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的条件没有满足。
3.患者去做检查,类似于线程进入等待状态;然后大夫叫下一个患者,这个步骤我们在前面等待-通知机制忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁。
4.患者做完检查,类似于线程要求的条件已经满足;患者拿检测报告重新分诊,类似于线程重新获取互斥锁,这个步骤在我们前面的等待-通知机制中忽视了。
所以完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不能满足时,释放互斥锁,进入等待状态;当要求满足的时,通知等待的线程,重新获取互斥锁。

1.5.2用sychronized 实现等待-通知机制

Java语言中内置的synchronized配置wait()、notify()、notifyAll()这三个方法就能轻松实现。
用synchronized实现互斥锁。在下面的图中,左边有一个等待队列,同一时刻,只允许一个线程进入synchronized保护的临界区,当一个线程进入临界区后,其他线程就只能进入图中左边的等待队列等待。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
在这里插入图片描述
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java对象的wait()方法就能满足这种需求。入上图所示,当调用wait()之后,当前的线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他的线程就有机会获得锁,进入临界区了。
那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是Java对象的notify()和notifyAll()方法。下面图中所示,当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
在这里插入图片描述
为什么说,曾经满足过?因为notify()只能保证在通知的时间点,条件满足的。而被通知线程的执行时间点和通知时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点需要格外注意。
除此之外,还有一点需要注意,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用wait()时已经释放了)。
上面我们一直强调wait()、notify()、notifyAll()方法操作的等待队列是互斥锁的等待队列,所以如果synchronized锁定的是this,那么对应的一定是this.wait()、this.notify()、this.notifyAll()。
而且wait()、notify()、notifyAll()这三个方法能够调用的前提是已经获取了相应的互斥锁,所以我们 会发现这三个都在Synchronized{}内部调用的。如果在Synchronized{}外部调用,或者锁定this,而调用target.wait()调用的话,JVM会抛出一个运行时异常:java.lang.IllegalMonitorStateException。

1.5.3一个更好的资源分配器

如何解决一次性申请转出账户和转入账户的问题。在这个等待-通知机制中,我们需要考虑以下四个要素。
1.互斥锁:上一节中我们提到Allcator需要是单例的,所以我们可以用this作为互斥锁。
2.线程要求的条件:转出账户和转入账户都没有被分配过。
3.何时等待:线程要求的条件不满足就等待。
4.何时通知:当线程释放账户时就通知。
将上面的几个问题考虑清楚,可以快速完成下面的代码。需要注意的是我们使用了:

While(条件不满足){
	wait()}

利用这个范式可以解决上面提到的条件曾经满足过这个问题。因为当wait()返回时,有可能条件已经发生变化了,曾经满足条件,但是现在已经不满足了,所以要重新先检验条件是否满足。

class Allocator{
	private List<Object> als;
	//一次性申请所有的资源
	synchronized void apply(Object from,Object to){
		//经典写法
		while(als.contains(from) || als.contains(to)){
			try{
				wait();
			}catch(Exception e){

			}
		}
		als.add(from);
		als.add(to);
	}

	synchronized void free(Object from,Object to){
		als.remove(from);
		als.remove(to);
		nofyAll();
	}
}

为什么不使用notify()呢?这两者室友区别的,notify()是随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有的线程。从感觉上来讲,应该是notify()更好,因为即便通知所有的线程,也只有一个线程能够进入临界区。但是存在风险,它的风险可能导致某些线程永远不会被通知到。
假设我们有资源A、B、C、D,线程1申请到了AB,线程2申请到了CD,此时线程3申请AB,会进入等待队列,线程4申请CD也会进入等待队列。我们再假设之后线程1归还了AB,如果使用notify()来通知等待队列中的线程,有可能被通知的是线程4,但线程4申请的是CD,所以此时线程4还是会继续等待,而真正该唤醒的线程3就再也没有机会被唤醒。所以除非深思熟虑,否则尽量使用notifyAll()。

1.5.4Wait和sleep的区别

1.Sleep是Thread的方法,wait是Object类的方法;
2.Sleep方法调用的时候必须指定时间
3.wait会释放锁而sleep不会释放资源
4.Wait只能在同步方法和同步块中使用,而sleep任何地方都可以
5.wait无需捕捉异常,而sleep需要。

1.6安全性、活跃性以及性能问题

并发编程中我们需要注意的问题,主要有三个方面,分别是:安全性问题,活跃性问题和性能问题。

1.6.1安全性问题

什么是线程安全呢,本质上就是真确性。而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。那如何写出线程安全的程序呢?在第一节已经介绍了并发Bug的三个来源:原子性问题、可见性问题和有序性问题。
那是不是所有的代码都需要分析是否存在这三个问题呢?当然不是,其实只有一种情况:存在共享数据并且该数据会发生变化,通俗的讲就是多个线程同时读写同一数据。那如果能够做到不共享数据和数据状态不发生变化,不就能够保证线程安全性了么。例如线程本地存储(thread Local Storage,TLS)、不变模式等等。
但是现实生活中,必须共享会发生变化的数据。当多个线程同时访问同一个数据,并且至少有一个线程会写这个数据的时候,如果我们不采取保护措施,那么就会导致并发Bug,即数据竞争。

public class Test{
	private long count = 0;
	void add10K(){
		int idx = 0;
		while(idx ++ < 10000){
			count += 1;
		}
	}
}

那是不是在访问数据的地方,我们加个锁保护一下就可以解决所有的问题呢。
public class Test{
	private long count = 0;
	sychronized long get(){
		return count;
	}
	sychronized void set(long v){
		count = v;
	}

	void add10K(){
		int idx = 0;
		while(idx ++ < 10000){
			set(get() +1);
		}
	}
}

假设count=0,当两个线程同时执行get方法时,都会返回0。当线程执行get()+1,结果都是1,之后两个线程再将结果1写入内存,你本来期望的是2,而结果却是1。
这个问题叫做竞态条件(Race Condition)。所谓的竞态条件。所谓竞态条件,指的是程序的执行结果依赖线程的执行顺序。例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2.在并发环境内,线程的执行顺序是不确定的,如果存在竞态条件问题,那就意味着程序执行的结果不确定。
下面再结合一个例子来说明下竞态条件。转账操作里面有个判断条件–转出金额不能大于账户余额,但在并发环境里面,如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。假设账户账户A有余额200,线程1 和线程2都要从账户A转出150,在下面的代码里,有可能线程1和线程2出现超额转出的情况。

class Account{
	private int balance;
	//转账
	void transfer(Account target,int awt){
		if(this.balance > amt){
			this.balance -= amt;
			target.balance += amt;
		}
	}
}

所以你也可以按照下面这样理解竞态条件。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面的样子:

if(状态变量 满足 执行条件){
	执行操作
}

当某个线程发现状态变量满足执行条件的时候,开始执行操作;可是就在线程执行操作的时候,其他的线程同时修改了状态变量,导致状态变量不满足执行条件了。当然很多场景下,这个条件不是显示的。
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这是两类问题,都可以用互斥这个方案,而实现互斥的方案很多,CPU提供了相关互斥指令,操作系统、编程语言也提供了相关的API。
从逻辑上看,我们统一归为锁。

1.6.2活跃性问题

所谓活跃性问题,指的是某个操作无法执行下去,我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
之前的章节讲过,发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程的永久地“阻塞”了。
但有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的活锁。可以类比现实世界里的例子,路人甲从左手门出门,路人乙从右手门进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果两人又相撞了。这种情况,基本谦让几次就解决了,因为人会交流。但是在程序世界里,就有可能会一直没完没了的“谦让”下去,导致“活锁”。
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。“等待一个随机的时间”的方案虽然简单,却非常有效。
那“饥饿”问题该怎么去理解?所谓“饥饿”指的是线程因为无法访问所需资源而无法执行下去的情况。如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”的问题。
解决饥饿的问题方案很简单,一般有三种方案:一是保证资源充足,二是公平地分配资源,三是避免持有锁的线程长时间执行。这三个方案,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程的执行时间也很难缩短。倒是第二种适用场景相对来说比较容易。
那如何公平地分配资源呢?在并发编程里,主要使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获取资源。

1.6.3性能问题

使用“锁”要非常小心,但是如果小心过度,也可能出现“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
所以使用锁的时候一定要关注性能的影响,那怎么才能避免锁带来的性能问题?这个问题很复杂,Java SDK并发包里之所以有那么多东西,有很大一部分的原因要提升在某个特定领域的性能。
不过从方案的层面,我们可以这样来解决问题。
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储(Thread Local Storage,TLS),写入时复制(Copy-on-write),乐观锁等;java 并发包里面的原子类也是一种无锁的数据结构。
第二,减少锁持有的时间,互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也很多,例如使用细粒度的锁,一个典型的例子就是java并发包里的ConcurrentHashMap,它使用了所谓的分段锁的技术;还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
性能方面的度量指标有很多,可以用三个指标度量:吞吐量、延迟和并发量。
1.吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
2.延迟:指的是从发出请求到收到请求的响应时间。延迟越小,说明性能越好。
3.并发量:指的是能同时处理请求数量,一般来说随着并发量的增加,延迟也会增加。所以这个延迟的指标,一般都会基于并发量来说的。例如并发量是1000的时候,延迟是50毫秒。

1.7管程(并发编程的万能钥匙)

1.7.1什么是管程

Java在1.5之前仅仅提供了Synchronized关键字及wait()、notify()以及notifyAll()方法。操作系统中使用的是信号量,管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能使用信号量实现管程。但是管程容易实现。
管程对应的英文是Monitor,所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们并发。翻译为JAVA领域的语言,就是管理类成员变量和成员方法,让这个类是线程安全的。
管程在发展史上,先后出现三种不同的模型,分别是:Hasen模型、Hoare模型和MESA模型。其中,现在广泛应用的是MESA模型,并且JAVA管程的实现参考也是MESA模型。

1.7.2MESA模型

在并发领域,有两大核心问题:一是互斥,即同一时刻只允许一个线程访问共享变量;另外一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
1.互斥问题
管程能够解决互斥问题的思路很简单,就是共享变量及其共享变量的操作统一封装在一起。在下图中,管程X将共享变量queue这个队列和相关操作入队enq()、出队dep()都封装起来了;线程A和线程B如果想要访问共享变量queue,只能通过调用管程提供的enq()、deq()方法实现;enq()、deq()保证互斥性,只允许一个线程进入管程,不知你有没有发现,管程模型和面向对象高度契合。
在这里插入图片描述
2.同步问题
那管程如何解决线程间同步的问题呢?
类比于就医流程,在如下图的管程模型里,共享变量和对共享变量的操作时被封装的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁还有一个等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似于就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
在这里插入图片描述
管程还引入了条件变量的概念,而且每个条件变量都对应一个等待队列,如下图,条件变量A和条件变量B分别都有自己的等待队列。
那条件变量和等待队列的作用是什么呢?其实就是解决线程的同步问题。
假设有个线程T1执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不空这个前提条件就是管程里条件变量。如果线程T1进入管程后恰好发现队列是空的,那怎么办呢?等待,去哪里等待呢?就去条件变量对应的等待队列里面等,此时线程T1就去“队列不空”这个条件变量的等待队列中等待,这个过程类似于大夫发现你没有验血,于是给你开个验血的单子,你呢就去验血的队伍里排队。线程T1进入条件变量的等待队列后,是允许其他线程进入管程的。这和你去验血的时候,医生给其他患者诊治。
再假设之后另外一个线程T2执行入队操作,入队操作执行成功之后,“队列不空”这个条件对于线程T1来说已经满足了,此时线程T2要通知T1,告诉它需要的条件已经满足了。当线程T1得到通知之后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。这个过程类似你验血完,回来找大夫,需要重新分诊。
条件变量及其等待队列已经讲清楚了,下面说说wait()、notify()以及notifyAll()这三个操作。前面提到线程T1发现“队列为空”这个条件不满足,需要进到对应的等待队列里面等待。这个过程就是通过调用wait()来实现的。如果我们用对象A代表“队列为空”这个条件,那么线程T1需要调用A.wait()。同理当“队列不空”这个条件满足时,线程T2需要调用A.notify()来通知A等待队列中的一个线程,此时这个队列里面只有线程T1。至于notidyAll()这个方法,它可以通知等待队列中的所有的线程。

1.7.3wait()的正确使用

有一点需要注意的,对于MESA管程来说,有一个编程范式,就是需要在一个while循环里面调用wait()。这个是MESA管程持有的。

while(条件不满足){
	wait();
}

Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程T2的操作使线程T1的等待条件满足时,T1和T2究竟谁可以执行呢?
1.Hasen模型里面,要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
2.Hoare模型里面,T2通知完T1后,T2阻塞,T1马上执行;再唤醒T2,也能保证同一时刻只有一个线程执行。但是相比于Hasen模型,T2多一次阻塞唤醒操作。
3.MESA管程里面,T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进入到入口等待队列里面。这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。但是也有个副作用,就是当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
尽量使用notifyAll(),notify()何时可以使用,需要满足以下三个条件:
1.所有等待线程拥有相同等待条件
2.所有等待线程被唤醒后,执行相同的操作;
3.只需要唤醒一个线程。

1.8Java线程

1.8.1Java线程的生命周期

虽然不同的语言对于操作系统线程进行了不同的封装,但是对于线程的生命周期这部分,基本上是雷同的。所以,我们可以先来了解一下通用的线程生命周期,这部分内容也适用于其他的编程语言;然后在详细的学习Java中的线程的生命周期。

1.8.1.1通用的线程生命周期

通用的线程生命周期基本上可以用下图这个“五态图”来描述。这五态分别是:初始状态,可运行状态,运行状态,休眠状态和终止状态。
在这里插入图片描述
这“五态模型”的详细情况如下:
1.初始状态:指的是线程已经被创建,但是不允许分配CPU执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有被创建。
2.可运行状态:指的是线程可以分配CPU执行。在这种状态下,真正的操作系统已经被成功创建成功了,所以可以分配CPU执行。
3.当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了运行状态。
4.运行状态的线程如果调用阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了,线程就会从休眠状态转换为可运行状态。
5.线程执行完成或者出现异常就会进入终止状态,终止状态的线程就不会切换到其他任何状态,进入终止状态也意味着线程的生命周期结束了。
这五种状态在不同的编程语言会有简化合并。Java中把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而JVM层面不关心这两个状态,因为JVM把线程交给操作系统处理了。
除了简化合并,这五种状态也有可能被细化,比如,java语言里就细化了休眠状态。

1.8.1.2 java中线程的生命周期

Java语言中的线程共有六种状态,分别是:
1.NEW (初始化状态)
2.RUNNABLE(可运行/运行状态)
3.BLOCKED(阻塞状态)
4.WAITING(无限等待)
5.TIMED_WAITING(有限时等待)
6.TERMINATED(终止状态)
其实在操作系统层面,Java中的BLOCKED、WAITING、TIMED_WAITING是一种状态,即我们之前提到过的休眠状态。这个线程永远没有CPU使用权。
所以JAVA生命周期如下:
在这里插入图片描述
其中,BLOCKED、WAITING、TIMED_WAITING可以理解为线程导致休眠状态的三种原因。

1.8.1.3 RUNNABLE与BLOCKED的状态转换

只有一个场景会触发这种转换,就是线程等待synchronized的隐式锁。Synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从RUNNABLE转换到BLOCKED状态,而当等待的线程获得synchronized隐式锁时,就会从BLOCKED转换为RUNNABLE状态。
线程调用阻塞式API时,是否会转换到BLOCKED状态呢?在操作系统层面,线程是会转到休眠状态的,但是在JVM层面,JAVA线程的状态不会发生变化,也就是说java线程的状态会依然保持RUNNABLE状态。JVM层面并不关心操作系统调度的具体状态,因为在JVM看来,等待CPU的使用权(操作系统层面此时处于可执行状态)与等待I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了RUNNABLE状态。
而我们平时所谓的JAVA在调用阻塞API时,线程会阻塞,指的是操作系统线程的状态,而不是JAVA线程的状态。

1.8.1.4 RUNNABLE与WAITING的状态转换

总体来说,有三个场景会触发这种转换。
第一种场景,获得sychronzied隐式锁的线程,调用无参数的Object.wait()。
第二种场景,调用无参的Thread.join()方法。其中的join()是一种线程同步的方法,例如有一个线程对象thread A,当调用.join()的时候,执行这条语句的线程会等待threadA执行完,而等待中的这个线程,其状态会从RUNABLE转换为WAITING。当线程thread执行完,原来等待它的线程又会从WAITING状态转换到RUNNABLE。
第三种场景,调用LockSupport.park()方法,当调用LockSupport.park()线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpark(),可唤醒目标线程,目标线程会从WAITING转换为RUNNABLE。

1.8.1.5 RUNNABLE与TIMED_WAITING的状态转换

有五种场景会触发这种转换。
1.调用带超时参数的Thread.sleep(long millis)方法;
2.获得synchronized隐式锁的线程,调用带超时参数的Object.wait(long timeout)方法;
3.调用待超时参数的Thread.join(long mills)方法;
4.调用带超时参数的LockSupport.parkNanos(Object blocker,long deadline)方法;
5.调用带超时参数的LockSupport.parkUntil(long deadline)方法。

1.8.1.6 从NEW到RUNNABLE的状态转换

Java刚创建出来的Thread对象就是NEW状态,而创建Thread对象主要有两种方法。一种是继承Thread对象,重写run()方法。示例代码如下:

 class MyThread extends Thread{
 	public void run(){
 		//线程需要执行的代码
 		...
 	}
 }
 //创建线程对象
 MyThread myThread = new MyThread();
另一种是实现Runnable接口,重写run()方法,并将改实现作为创建Thread对象的参数。示例代码如下:
 //实现Runnable接口
class Runner implements Runnable{
	@Override
	public void run(){
		//线程需要执行的代码
		...
	}
}
//创建线程对象
Thread thread = new Thread(new Runner());

NEW状态的线程,不会被操作系统调度,因此不会执行。JAVA线程要执行,就必须转换到RUNNABLE状态。从NEW状态转化到RUNNABLE状态很简单,只要调用线程对象的start()方法就可以了,实例代码如下:

MyThread myThread = new MyThread();
myThread.start();
1.8.1.6 从RUNNABLE到TERMINATED的状态

线程执行完 run() 方法后,会自动转换到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如 run()方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java的Thread类里面倒是有个stop()方法,不过已经标记为@Deprecated,所以不建议使用了。正确的姿势其实是调用interrupt()方法

1.8.1.7 stop()和interrupt()方法的主要区别是什么

stop()方法会真的杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unLock()去释放锁,那其他线程就再也没机会获得ReentrantLock锁,所以该方法不建议使用。类似上面的方法还有suspend()和resume()方法,这两个方法同样也都不建议使用。
而interrupt()方法就温柔很多了,interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可可以无视这个通知。被interrupt的线程,是怎么收到通知的呢?一种是异常,一种是主动检测。
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,我们看到这些方法的签名,发现都会throws InterruptedException这个异常。这个异常的触发条件就是:其他的线程调用了该线程的interrupt()方法。
当线程A处于RUNNABLE状态时,并且阻塞在java.nio.channels. InterruptibleChannel上时,如果其他线程调用线程A的interupt()方法,线程A会触发java.nio.channels.CloseByInterruptException这个异常;而阻塞在Java.nio.channel.Selector上时,如果其他的线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。
上面的情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于RUNNABLE状态,并且没有阻塞在某个I/O操作上时,例如中断计算圆周率的线程A,这时候就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法,那么线程A可以通过isInterrupted()方法,检测是不是自己被中断。

1.8.2创建合适数量的线程

在java领域,实现并发程序的主要手段就是多线程,使用多线程还是比较简单的,但是使用多少个线程却是困哪的问题。要解决这个问题,首先要分析以下两个问题:
1.为什么要使用多线程?
2.多线程的应用场景有哪些?

1.8.2.1 为什么要使用多线程

使用多线程,本质上就是提升程序性能。但是首要的问题:如何度量性能。
度量性能的指标有很多,但是两个指标是最核心的,它们就是延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也越好。吞吐量指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序执行得越快,性能也越好。吞吐量指的是在单位时间内能够处理请求数量;吞吐量越大,意味着程序能够处理的请求越多,性能也越好。这两个指标内部有一定的关联(同等条件下,延迟越短,吞吐量越大),但是由于他们隶属于不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。
我们所谓的性能,从度量的角度,主要是降低延迟,提高吞吐量。这个也是我们使用多线程的原因。

1.8.2.2 多线程的应用场景

要想“降低延迟,提高吞吐量”,对应的方法呢?基本有两个方向,一个方向是优化算法,另外一个是将硬件的性能发挥到极致。前者属于算法的范畴,后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢?主要是两类:一个是I/O,一个是CPU。简而言之,在并发领域,提升性能的本质上就是提升硬件的利用率,再具体点来说,就是提升I/O的利用率和CPU的利用率。
下面用一个简单的示例来说明:如何利用多线程来提升CPU和I/O的利用率?假设程序按照CPU计算和I/O操作交叉的方式运行,而且CPU计算和I/O操作的耗时是1:1。
如下图所示,如果只有一个线程,执行CPU计算的时候,I/O设备空闲;执行I/O操作的时候,CPU空闲,所以CPU的利用率和I/O设备的利用率都是50%。
在这里插入图片描述
如果有两个线程,如下图所示,当线程A执行CPU计算的时候,线程B执行I/O操作;当线程A执行I/O操作的时候,线程B执行CPU计算,这样的CPU的利用率和I/O设备的利用率就都达到了100%。
在这里插入图片描述
因此如果CPU和I/O设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量。
在单核时代,多线程主要就是用来平衡CPU和I/O设备的。如果程序只有CPU计算,而没有I/O设备的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多线程可以降低响应时间。
为了便于理解,这里举个简单的例子说明一下:计算1+2+… …+ 10亿的值,如果在4核的CPU上利用4个线程执行,线程A计算[1,25亿),线程B计算[25亿,50亿),线程C计算[50,75亿),线程D计算[75亿,100亿),之后汇总,那么理论上应该比一个线程计算[1,100亿]快将近4倍,响应时间能够降低到25%。一个线程,对于4核的CPU,CPU的利用率只有25%,而4个线程利用率为100%。

1.8.2.3 创建多少个线程合适

创建多少线程合适,要看多线程具体的应用场景。我们程序一般都是CPU计算和I/O操作交叉执行,由于I/O设备的速度相对于CPU来说很慢,所以大部分情况下,I/O操作执行的时间相对于CPU计算来说都非常长,这种场景我们一般称之为I/O密集型计算;和I/O密集型计算相对的就是CPU密集型计算了,CPU密集型计算大部分场景下都是纯CPU计算。I/O密集型程序和CPU密集型程序,计算最佳线程数的方法是不同的。
对于CPU密集型计算,多线程本质上是提升多核CPU的利用率,所以对于一个4核的CPU,每个核一个线程,理论上创建4个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于CPU密集型的计算场景,理论上“线程的数量=CPU核数”就是最合适的。不过工程上,线程的数量一般会设置为“CPU核数+1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证CPU的利用率。
对于I/O密集型的计算场景,比如前面我们的例子中,如果CPU计算和I/O操作的耗时是1:1,那么2个线程是最合适的。如果CPU计算和I/O操作的耗时是1:2,那多少个线程合适呢?是3个线程,如下图所示:CPU在A、B、C三个线程之间切换,对于线程A,当CPU从B、C切换回来时,线程A正好执行完I/O操作。这样CPU和I/O设备的利用率都达到了100%。
通过上面的例子,我们会发现,对于I/O密集型计算的场景,最佳的线程数是与程序中CPU计算和I/O操作的耗时相关的,我们可以总结出这样的公式:
最佳线程数=1+(I/O耗时/CPU耗时)
我们令R=I/O耗时,可以解释为:当线程A执行I/O操作时,另外R个线程正好执行完各自的CPU计算。这样CPU的利用率就达到了100%。
不过上面的公式针对单核CPU的,至于多核CPU,也很简单,只需要等比扩大就可以了,计算公式如下:
最佳的线程数=CPU核数*[1+(I/O耗时/CPU耗时)]

1.8.3为什么局部变量是线程安全的

前面小节讲到,多个线程同时访问共享变量的时候,会导致问题。在Java语言里,java方法里面的局部变量是否存在并发问题呢?以下为例。
比如:下面的代码里的fibonacci()方法,会根据传入的参数n,返回1到n的斐波那契数列,斐波那契数列类似这样:1、1、2、3、5、8、13、21、34… …。在这个方法里面,有个局部变量:数组r用来保存数列的结果,每次计算完一项,都会更新数组r对应位置中的值。那么当多个线程调用fibonacci()这个方法的时候,数组r是否存在数据竞争(Data Race)呢?

 //返回斐波那契数列
 int[] fibonacci(int n){
 	//创建结果数组
 	int[] r = new int[n];
 	//初始化第一、第二个数
 	r[0]=r[1]=1;
 	//计算2..n
 	for(int i = 2; i < n; i++){
 		r[i] = r[i-2] + r[i-1];
 	}
 	return 1;
 }
1.8.3.1 方法被如何执行

高级语言里的普通语句,例如上面的r[i]=r[i-2]+r[i-1];翻译成CPU的指令相对简单,可方法调用比较复杂了。例如下面这三行代码:第一行,声明一个int变量a;第2行,调用方法fibonacci(a);第3行,将b赋值给c。

int a= 7;
int[] b = fibonacci(a);
int[] c = b;

当你调用int[] b = fibonacci(a)的时候,CPU要找到方法fibonacci()的地址,然后跳转到这个地址去执行代码,最后CPU执行完方法fibonacci()之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c = b;的地址,再跳转到这个地址去执行。你可以参考如下的图来加深理解。
在这里插入图片描述
CPU通过CPU的堆栈寄存器找到调用方法的参数和返回地址。例如,有三个方法A、B、C,他们的调用关系是A->B->C,在运行时,会构建出下面这样的调用栈。每个方法在调用栈都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死。
在这里插入图片描述
利用栈结构来支持方法调用这个方案非常普遍,以至于CPU里内置了栈寄存器。

1.8.3.2 局部变量存哪里

方法内的局部变量存哪里?局部变量的作用域是方法内部,也就是是当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈栈就是和方法同生共死的,所以局部变量放到调用栈里相当的合理。事实上,的确这样的,局部变量就是放到调用栈里。于是调用栈的结构就变成下图这样。
在这里插入图片描述
局部变量在栈里,和方法同生共死,一个变量如果想跨越方法的边界,就必须创建在堆里。

1.8.3.3 调用栈与线程

两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:每个线程都有自己独立的调用栈。因为如果不是这样,那两个线程就相互干扰了。入下图所示,线程A、B、C每个线程都有自己独立的调用栈。

Java方法里面的局部变量是否存在并发问题,答案是肯定不会存在,因为每个线程都有自己独立的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。

1.8.3.4 线程封闭

方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,就做线程封闭,即仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题。
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的链接Connection,在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术,保证一个Connection一旦被一个线程获取之后,在线程关闭Connection之前的这段时间里,不会再分配给其他线程,从而保证了Connection不会有并发的问题。

1.9Java 如何面向对象的思想写好并发程序

在Java语言里,面向对象思想能够让并发编程变得更简单。
那如何才能用面向对象思想写好并发程序呢?可以从封装共享变量、识别共享变量间约束条件和制定并发访问策略这三方面下手。

1.9.1封装共享变量

并发程序,核心问题是解决多线程同时访问共享变量的问题。面向对象思想里有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外届对象只能通过目标对象提供的公共方法来间接访问这些内部属性。利用面向对象思想些并发程序的思路,很简单:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。就拿很多统计程序都要用到的计算器来说,下面的计数器程序共享变量只有一个,就是value,我们把它作为Counter类的属性,并且将两个公共的方法get()和addOne()声明为同步方法,这样Counter类就成为一个线程安全的类了。

public class Counter(){
	private long value;
	sychronized long get(){
		return value;
	}
	synchronized long addOne(){
		return ++value;
	}
}

当然在实际工作中,很多场景都不会像计数器那么简单,往往有很多的共享变量,例如,信用卡账户有卡号、姓名、身份证、信用额度、已出账单、未出账单等很多共享变量。这么多的共享变量,如果每一个都考虑它的并发安全问题,不现实。但是你会发现好多的共享变量的值是不会变的,例如信用卡账户的卡号、姓名、身份证。对于这些不会发生变化的共享变量,建议你用final关键字来修饰。这样既能避免并发问题,也能很明了地表名你的设计意图。

1.9.2识别共享变量间的约束条件

识别共享变量间的约束条件非常重要。因为这些约束条件,决定了并发访问策略。例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,他有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。在类SafeWM中,声明了两个成员变量upper和lower,分别代表库上限和库存下限,这两个变量用了AtomicLong这个原子类,原子类是线程安全的,所以这两个成员变量的set方法就不需要同步了。

public class SafeWM{
	//库存上限
	prvate final AtomicLong upper = 
		new AtomicLong(0);
	//库存下限
	private final AtomicLong lower = 
		new AtomicLong(0);

	//设置库存上限
	void setUpper(long v){
		upper.set(v);
	}

	//设置库存下限
	void setLower(long v){
		lower.set(v);
	}
	//省略其他的业务代码
}

虽然说上面的代码没有问题,但是忽略一个约束条件,就是库存下限要小于库存上限。如果我们在setUpper()和setLower()中增加了参数校验,可能会存在并发问题,问题在于存在竞态条件。
我们假设库存的下限和上限分别是(2,10),线程A调用setUpper(5)将上限设置为5,线程B调用setLower(7)将下限设置为7,如果线程A和线程B完全同时执行,你会发现线程A能够通过参数校验,因为这个时候,下限还没有被线程B设置,还是2,而5>2;线程B也能够通过参数校验,因为这个时候,上限还没有被线程A设置,还是10,而7<10.当线程A和线程B通过参数校验后,就把库存的下限和上限设置成(7,5)了,显然此时的结果不符合库存的上限要小于下限这个约束条件的。
共享变量之间的约束条件,反映在代码里,基本上都会有if语句,所以,一定要特别注意静态条件。

1.9.3制定并发访问策略

制定并发访问策略,是一个非常复杂的事情。但是长方案看,无外乎就是以下“三件事”。
1.避免共享:避免共享的技术主要利用线程本地存储以及为每个任务分配独立的线程。
2.不变模式:这个在Java领域应用很少,但是在其他领域有广泛的应用,例如Actor模式、CSP模式以及函数式编程的基础就是不变模式。
3.管程及其他同步工具:Java领域万能的解决方案是管程,但是对于很多特定的应用场景,使用Java并发包提供的读写锁、并发容器等同步工具会更好。
除了这些以外,还有一些宏观的原则需要了解一下。这些宏观 的原则,有助于写出“健壮”的并发程序。这些原则主要有以下的三条。
1.优先使用成熟的工具类:Java SDK并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议优先使用;
2.迫不得已使用低级的同步原语;低级的同步原语主要指synchronized、Lock、Semaphore;
3.避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后在优化。在设计期和开发期,很多人经常情不自禁地预估性能的瓶颈,并对此实施优化,但是性能不是你能预估的。

1.10本章小结

起源是一个硬件的核心矛盾:CPU与内存、I/O的速度差异,系统软件(操作系统、编译器—)在解决在解决这个核心矛盾问题的同时,引入了可见性、原子性和有序性问题,这三个问题是很多并发编程的bug来源。这是第一章的内容。
那如何解决这个问题呢?Java提供了内存模型和互斥锁方案。所以,在02我们介绍了Java内存模型,以应对可见性和有序性问题;那另外一个原子性问题该如何解决?多方考量用互斥锁,这是03的内容。
虽说互斥锁是解决并发问题的核心工具,但它也可能带来死锁的问,所以04介绍了死锁产生的原因以及解决方案;同时还引出了一个线程间协作的问题,这就是是05的内容,介绍了线程间的协作机制:等待-通知。
前五节的内容是站在微观的角度看待并发问题。而06则是换一个角度,站在宏观的角度重新审核并发编程相关的概念和理论。
07节介绍管程,是Java并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心核心问题:互斥和同步,都是由管程来解决的。
08介绍了线程相关的知识,毕竟Java并发编程是要靠多线程来实现的,所以有针对性地学习这部分知识也是很有必要的,包括线程的生命周期、如何计算合适的线程数以及线程内部如何执行的。
最后,在09节还介绍了如何面向对象思想写好并发程序,因为在Java语言里,面向对象思想能够让编程变得更简单。
在这里插入图片描述

2 并发工具类

2.1Lock和Condition

在并发编程的领域,两大核心问题:互斥和同步。互斥是同一时刻只允许一个线程访问共享资源;同步是指,即线程间如何通讯、协作。这两大问题,管程都是能够解决的。Java Sdk并发包中通过Lock和Condition两个接口来实现管程,其中Lock用于解决互斥问题,Condition解决同步问题。

2.1.1Lock

2.1.1.1再造管程的理由

在Java1.5版本中,synchronized性能不如SDK里面的Lock,但1.6版本之后,synchronized做了很多优化,将性能追了上来,所以1.6之后又有人推荐使用synchronized了。那为什么还要重复造轮子呢。
关于这个问题,你可能想到在“死锁问题”那节,提出了一个破坏不可抢占条件方案,但是这个方案synchronized没有办法解决。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞的状态了,而线程进入阻塞状态,啥都不干了,也释放不了线程已经占有的资源。但我们希望的是:
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到可以主要释放它占有的资源,这样不可抢占这个条件就被破坏了。
那如果需要重新设计一把互斥锁来解决这个问题,那该怎么设计呢?我觉得有三个方案。
1.能够响应中断。Synchronized的问题是,持有A锁后,如果尝试获取B失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程,但如果阻塞线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能唤醒它,那它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。
2.支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
3.非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会会释放曾经持有的锁。这样也能破坏不可抢占条件。
这三种方案可以全面弥补synchronized的问题。这就是需要重复造轮子的原因,体现在API上,就是Lock接口的三个方法。详情如下:
//支持中断的API
void lockInterruptibly() throws InterruptedException;
//支持超时的API
boolean tryLock(long time,TimeUnit unit) throws InterruptedException;
//支持非阻塞获取锁的API
boolean tryLock();

2.1.1.2如何保证可见性

Java SDK里面的Lock的使用,有一个经典的范例,就是try{}finally{},需要重点关注的是在finally里面释放锁。那可见性怎么保证呢,它利用的是volatile相关的 Happens-Before规则。Java SDK里面的ReentrantLock,内部持有一个volatile的成员变量state,获取锁的时候,会读写state的值;解锁的时候,也会读写state的值(简化后的代码如下)。也就是说,在执行value+=1之后,又读写了一次volatile变量的state。根据Happens-Before规则:
1.顺序性规则:对于线程T1,value+=1 Happens-Before释放锁的操作unlock();
2.Volatile变量的规则:由于state =1 会先读取state,所以线程T1的unlock()操作Happens-Before线程T2的lock()操作;
3.传递性规则:线程T1的a=value+1 happens_before线程T2的lock()操作。

class SampleLock{
	volatile int state;
	//加锁
	Lock(){
		//省略部分代码
		state = 1;
	}
	//解锁
	unlock(){
		//省略部分代码
		state = 0;
	}
}
2.1.1.3什么是可重入锁

所谓可重入锁,顾名思义,指的是线程可以重新获取同一把锁。例如下面的代码,当线程T1执行到1处时,已经获取到了锁rt1,当在1处调用get()方法时,会在2再次对锁rt1执行锁操作,此时,如果锁rt1是可重入的,那么线程T1可以再次加锁成功;若果锁rt1是不可重入的,那么线程T1此时会被阻塞。

class X{
	private final Lock rt1 = new ReentrantLock();
	int value;
	public int get(){
		//获取锁
		rt1.lock();           //2
		try{
			return value;
		}finally{
			//保证锁能释放
			rt1.unlock()
		}
	}

	public void addOne(){
		//获取锁
		rt1.lock()
		try{
			value = 1 + get();   //1
		}finally{
			//保证锁能释放
			rt1.unlock();
		}
	}
}

除了可重入锁,可能你还听说过可重入函数,可重入函数怎么理解?所谓可重入函数指的是多个线程可以同时调用函数,每个线程都能得到正确结果:同时在一个线程内支持线程切换,无论切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换,这意味着线程安全。所以可重入函数是线程安全的。

2.1.1.4 公平锁和非公平锁

在使用ReentrantLock的时候,你会发现ReentrantLock这两个类的构造函数,一个是无参的构造函数,一个是传入fair参数的构造函数。fair参数代表的是锁的公平策略。如果传入true就表示需要构造一个公平锁,反之则代表要构造一个非公平锁。
无参构造函数:默认非公平锁

 public ReentrantLock(){
 	sync = new NonfairSync();
 }

 //根据公平策略参数创建锁
 public ReentrantLock(boolean fair){
 	sync = fair ? new FairSync() : new NonfairSync();
 }

在<<管程:并发编程的万能钥匙>>中,我们介绍过入口等待队列,锁对应着一个等待队列,如果一个锁没有获取锁,就会进入等待队列,当线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程,如果是公平锁,唤醒的就是等待时间最长的。而非公平锁,是随机的。

2.1.1.5 用锁的最佳实践

三个用锁的最佳实践:
1.永远只在更新对象的成员变量时加锁
2.永远只在访问可变变量的成员时加锁
3.永远不再调用其他对象方法时加锁
尤其最后一条,可能你觉得会过于严苛。但是最好时是去遵守,因为调用其他对象的方法,实在是太不安全了,也许“其他”方法里面有线程sleep()的调用,也可能会有奇慢的,比如I/O操作,这些都会严重影响性能。更可拍的是,“其他”类的方法可能会加锁,然后双重加锁就有可能导致死锁。
并发问题,本来就难以诊断,所以你一定要让你的代码尽量安全,尽量简单,哪怕有一点可能出现问题,都要努力避免。

2.1.2Condition

上一个小节我们将到Java SDK并发包里的Lock有别于synchronied 隐藏锁的三个特性:能够响应中断、支持超时和非阻塞地获取锁。这节讲述Java SDK并发包中里的Condition,Condition实现了管程模型里面的条件变量。
在很多并发场景下,支持多个条件变量能够让我们的并发程序可读性更好,实现起来也更容易,例如,实现一个阻塞队列,就需要两个条件变量。

2.1.2.1 利用两个条件变量快速实现阻塞队列

一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另外一个是队列不满(队列已满不允许入队),相关代码。

public class BlockedQueue<T>{
	final Lock lock = new ReentrantLock();
	//条件变量:队列不满
	final Condition notFull = lock.newCondition();
	//条件变量:队列不空
	final Condition notEmpty = lock.newCondition();

	//入队
	void enq(T x){
		lock.lock();
		try{
		  while(队列已满){
		  	//等待队列不满
		  	notFull.await();
		  }
		  //省略入队操作
		  //入队后,通知出队
		  notEmpty.signal();
		}finally{
			lock.unlock();
		}
	}

	//出队
	void deq(){
		lock.lock();
		try{
			while(队列以空){
				//等待队列不空
				notEmpty.await();
			}
			//省略出队操作
			//出队后,通知可入队
			notFull.singnal();
		}finally{
			lock.unlock();
		}
	}
}

不过,这里你需要注意,Lock和Condition实现的管程,线程等待和通知需要使用await()、signal()、signal(),它们的语义和wait()、notify()、notifyAll()是相同的。但是不一样的是,Lock&Condition实现的管程里只能使用前面的await()、signal()、signal(),而wait()、notify()、notifyAll()只能在Synchronized里面才能使用。

2.1.2.2 同步和异步

通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步,否则异步。
同步是Java代码默认的处理方式。如果你想让你的程序支持异步,可以通过下面的两种方式实现:
1.调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称之为异步调用;
2.方法实现的时候,创建一个新的线程执行主要逻辑,主要线程直接return,这种方法我们一般称之为异步方法。

2.1.2.3 Dubbo源码分析

其实在编程领域,异步场景还是很多的,比如TCP协议本身是异步的,我们工作中经常用到的RPC调用,在TCP协议层面,发送完RPC请求后,线程是不会等待RPC的响应结果的。可能你会奇怪,平时工作的中的RPC调用大多数都是同步的呀。
其实很简单,因为有人帮你做了这个事情,例如目前知名的RPC框架Dubbo就给我们做了异步转同步的事情。
对于下面的一个简单的RPC调用,默认情况下sayHello()方法,是个同步方法,也就是说,执行service.sayHello(“dubbo”)的时候,线程就会停下来等结果。
DemoService service = 初始化部分省略
String message = service.sayHello(“dubbo”);
System.out.println(message);

如果此时你将调用线dump出来的话,会发现调用的线程阻塞了,线程的状态是TIMED_WAITING。本来发送请求是异步的,但是调用线程却阻塞了,说明Dubbo帮我们做了异步转同步的事情。通过调用栈,你能看到线程是阻塞在DefaultFuture.get()方法上,所以可以推断:Dubbo异步转同步的功能应该是通过DefaultFuture这个类实现的。
不过为了理清后面的关系,还是有必要分析一下调用DefaultFuture.get()之前发生了什么。DubboInvoker里调用了DefaultFuture.get(),这一行很关键。

public class DubboInvoker{
	Result doInvoke(invocation inv){
		//下面是修改后的一行关键代码
		return currentClient
		.request(inv,timeout)
		.get();
	}
}

DefaultFuture这个类很关键,相关代码精简以后,如下。重复一下需求:当RPC返回结果之前,阻塞调用线程,让调用线程等待;当RPC返回结果后,唤醒调用线程,让掉用线程重新执行。下面看看Dubbo是怎么实现的。

//创建锁与条件变量
private final Lock lock = new ReentrantLock();
private final Condition done = lock.newCondition();

//调用方通过该方法等待结果
Object get(int timeout){
	long start = System.nanoTime();
	lock.lock();
	try{
		while(!isDone()){
			done.await(timeout);
			long cur = System.nanoTime();
			if(isDone() || cur - satrt > timeout){
				break;
			}
		}
	}finally{
		lock.unlock();
	}
	if(!isDone()){
		throw new TimeoutException();
	}
	return returnFromResponse();
}

//Rpc 结果是否已经返回
boolean isDone(){
	return response != null;
}

//RPC 结果返回时调用该方法
private void doReceived(Response res){
	lock.lock();
	try{
		response = res;
		if(done != null){
			done.signal();
		}
	}finally{
		lock.unlock();
	}
}

调用线程通过get()方法等待RPC返回结果,在这个方法里:调用lock()获取锁,在finally里面调用unlock()释放锁,通过经典的在循环中调用await()方法实现等待。
当RPC结果返回时,会调用doReceived()方法,这个方法里面,调用lock()获取锁,在finally里面调用unlock()释放锁,获取锁之后通过singal()来通知线程,结果已经返回,不用继续等待。

2.2Semaphore

Semaphore,现在普遍译为“信号量”,因为类似现实生活里的红绿灯,车辆 不能通行,要看是不是绿灯。同样,在编程世界里,线程能不能执行,也要看信号量是不是允许。

2.2.1信号量模型

信号量模型还是很简单的,可以简单概括为:一个计数器,一个等待队列,三个方法。在信号量的模型里,计数器和等待队列是对外透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down()和up()。你可以结合下面的图形象化的理解。
在这里插入图片描述
这三个方法详细的语义具体如下所示:
Init():设置计数器的具体值
Down():计数器的值减1;如果此时计算器的值小于0,则当前线程被阻塞,否则当前线程可以继续执行。
Up():计算器的值加1:如果此时计算器的值小于或者等于0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。

这里提到的init()、down()以及up()三个方法都是原子性的,并且这个原子性是由信号量模型的实现方保证的。在Java SDK中,信号量模型是由java.util.concurrent.Semaphore实现的,Semaphore这个类能够保证这个三个方法都是原子操作。可用下面的代码描述

class Semaphore{
	//计数器
	int count;
	//等待队列
	Queue queue;
	//初始化操作
	Semaphore(int c){
		this.count = c;
	}

	void down(){
		this.count--;
		if(this.count < 0){
			//将当前线程插入到等待队列
			//阻塞当前线程
		}
	}
	 void up(){
	 	this.count++;
	 	if(this.count <= 0){
	 		//移除等待队列中的某个线程T
	 		//唤醒线程T	
	 	}
	 }
}

信号量模型里面,down()、up()这两个操作历史上最早称为P操作和V操作,所以信号量模型也称之为PV原语。在JAVA JDK里面,down()和up()对应的则是acquire()和release()。

2.2.2如何使用信号量

使用累加器的例子来说明信号量如何使用吧。在累加器的例子里面,count+=1操作是个临界区,只允许一个线程执行,也就说要保证互斥,那这种情况用信号量怎么控制呢?
其实很简单,只需要在进入临界区之前执行以下down()操作,退出临界区之前执行一下up()操作就可以了。下面是java代码示例,acquire()就是信号量里面的down()操作,release()就是信号量里面的up()操作。

static int count;
//初始化信号量
static final Semaphore s = new Semaphore(1);
//用信号量保证互斥
static void addOne(){
	s.acquire();
	try{
		count += 1;
	}finally{
		s.release();
	}
}

下面我们再来分析一下,信号量是如何保证互斥的,假设两个线程T1和T2同时访问addOne()方法,当他们同时调用acquire()的时候,由于acquire()是一个原子操作,所以只能有一个线程(假设T1)把信号量的计数器减为0,所以线程T1会继续执行;对于线程T2,信号量里面的计数器的值是-1,小于0,按照信号量模型对down()操作的描述,线程T2将被阻塞。所以此时只有线程T1会进入临界区执行count+=1。
当线程T1执行release()操作,也就是up()操作,信号量里计数器的值是-1,加1知乎就变为0,小于等于0,按照信号量对up()操作的描述,此时可以唤醒等待队列中的T2。于是T2在T1执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。

2.2.3快速实现一个限流器

上面的例子,我们用信号量实现了一个简单的互斥锁功能。其实Semaphore可以允许多个线程访问一个临界区。
现实世界中,比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池、对象池、线程池等等。对象池,指的是一次性创建出N个对象,之后所有的线程重复使用N个对象,当然对象在被释放前,也是不允许其他线程使用的。对象池,可以用List保存所有的实例对象,这个很简单,但关键的是限流器的设计,这里的限流,指的是不允许多于N个线程同时进入临界区。

class ObjPool<T,R>{
	final List<T> pool;
	//用信号量实现限流器
	final Semaphore sem;
	//构造函数
	ObjPool(int size,T t){
		pool = new Vector<T>(){};
		for(int i = 0; i < size; i++){
			pool.add(t);
		}
		sem = new Semaphore(size);
	}

	//利用对象池的对象,调用func
	R exec(Function<T,R> func){
		T t = null;
		sem.acquire();
		try{
			t = pool.remove(0);
			return func.apply(t);
		}finally{
			pool.add(t);
			sem.release();
		}
	}
}

//创建对象池
ObjPool<Long,String> pool = new ObjPool<Long,String>(10,2);
//通过对象池获取t,之后执行
pool.exec(t -> {
	System.out.println(t);
	return t.toString();
});

上面的代码我们用一个List来保存对象实例,用Semaphore实现限流。关键的代码是ObjPool里面的exec()方法,这个方法里面实现了限流的功能。在这个方法中,我们首先调用acquire()方法(与之匹配的是在finally里面调用release()方法),假设对象池大小是10,信号量的计算器初始化为10,那么前10个线程调用acquire()方法,都能继续执行,而其他的线程就会阻塞在acquire()方法上。对于通过的线程,每个分配一个对象,之后执行一个回调函数,最后释放对象同时更新计数器的值。如果此时信号量里计数器的值小于等于0,那么说明线程在等待,此时会自动唤醒等待的线程。

2.3ReadWriteLock

读多写少的并发场景。实际工作中,为了优化性能,我们经常使用缓存,例如缓存元数据、缓存基础数据等,这就是一个典型的读多写少的场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化。
针对读多写少这种场景,java SDK并发包中提供了读写锁–ReadWriteLock,非常易用并且性能很好。
读写锁遵循三条基本原则:
1.允许多个线程同时读共享变量
2.只允许一个线程写共享变量
3.如果一个写线程正在执行写操作,此时禁止读线程读共享变量
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁不允许的,这就是读写锁在读多写少的场景下的性能优于互斥锁的关键。单读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作的。

2.3.1快速实现一个缓存

用ReadWriteLock快速实现一个通用的缓存工具类
在下面的代码中,我们声明了一个Cache<K,V>类,其中类型参数K代表缓存中Key类型,V代表缓存里value类型。缓存的数据保存在Cache类的HashMap里面,HashMap不是线程安全的,这里我们使用读写锁ReadWriteLock来保证其线程的安全性。
Cache这个工具类提供了两个方法,一个是读缓存方法get(),另外一个是写缓存的方法put()。读缓存需要用到读锁,读锁的使用和前面介绍的Lock使用是相同的,都是try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的方式也和读锁是类似的。

class Cache<K,V>{
	final Map<K,V> m = new HashMap<>();
	final ReadWriteLock rw1 = new ReentrantReadWriteLock();
	//读锁
	final Lock r = rw1.readLock();
	//写锁
	final Lock w = rw1.writeLock();
	//读缓存
	V get(K key){
		r.lock();
		try{
			return m.get(key);
		}finally{
			r.unlock();
		}
	}

	//写缓存
	V put(String key,Data v){
		w.lock();
		try{
			return m.put(key,v);
		}finally{
			w.unlock();
		}
	}
}

如果你曾经使用过缓存的话,你应该知道使用缓存首先需要解决缓存数据初始化的问题。缓存数据的初始化可以采用一次性加载的方式,也可以使用按需加载的方式。
如果源头的数据量不大的话,就可以采用一次性加载的方式,这种方式最简单。只需要在应用启动的时候把源头数据查询出来,依次调用类似上面的示例代码中的put()方法就可以实现了。
如果源头的数据量非常大,那么就需要按需加载了,按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关的数据进缓存的操作。下面利用ReadWriteLock()实现缓存的按需加载。

2.3.2实现缓存的按需加载

下面的代码实现了按需加载,我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到读写锁。

class Cache<K,V>{
	final Map<K,V> m = new HashMap<>();
	fianl ReadWriteLock rw1 = new ReentrantReadWriteLock();
	final Lock r = rw1.readLock();
	final Lock w = rw1.writeLock();

	V get(K key){
		V v = null;
		//读缓存
		r.lock();
		try{
			v = m.get(key);
		}finally{
			r.unlock();
		}

		//缓存中存在,返回
		if(v != null){
			return v;
		}
		//缓存中不存在,查询数据库
		w.lock();
		try{
			//两次验证
			//其他线程可能查询过数据库
			v = m.get(key);
			if(v == null){
				//查询数据库
				v=省略代码无数
				m.put(key,v);
			}
		}finally{
			w.unlock();
		}
		retrun v;
	}
}

另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据,而是重新验证了一次缓存中是否存在,再次验证不存在时,我们采取查询数据并更新本地缓存。
为什么要再次验证呢。原因是在高并发的场景下,有可能会有很多线程竞争写锁。假设缓存是空的,没有缓存任何东西,如果此时有三个线程T1、T2、T3同时调用get()方法,并且key也是相同的。那么它们会同时执行到下面,此时只有一个线程获得写锁,假设是线程T1,线程T1获取到写锁之后查询数据库并更新缓存,最终释放写锁。此时线程T2和T3会再有一个线程能够获取写锁,假设是T2,如果不采用再次验证的方式,此时T2会再次查询数据库。T2释放出来之后,T3会再次查询一次数据库。而实际上线程T1已经把缓存的值设置好了,T2、T3完全没有必要查询数据库。所以再次验证的方式,能够避免高并发场景下重复查询数据的问题。

2.3.3读写锁的升级和降级

上面按需加载的实例代码中,在1处获取到锁,在3处释放锁,那是否可以在2处的下面增加验证缓存并更新缓存的逻辑呢?

//读缓存
r.lock();           //1
try{
	v = m.get(key); //2
	if(v == null){
		w.lock();
		try{
			//再次验证并更新缓存
			//省略详细代码
		}finally{
			w.unlock();
		}
	}
}finally{
	r.unlock();
}	

这样看上去好像没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫做写的升级。但是ReadWriteLock并不支持升级。在上面的代码中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关的线程都被阻塞,永远没有机会被唤醒。锁的升级是不允许的,这个一定要注意。
不过,虽然锁的升级是不允许的,但是锁的降级是允许的。如下面的代码。

class CachedData{
	Object data;
	volatile boolean cacheVaild;
	final ReadWriteLock = rwl = new ReentrantReadWriteLock();
	//读锁
	final Lock r = rwl.readLock();
	//写锁
	final Lock w = rwl.writeLock();

	void processCachedData(){
		//获取读锁
		r.lock();
		if(!cacheVaild){
			//释放锁,因为不允许读锁的升级
			r.unlock();
			//获取写锁
			w.lock();
			try{
				//再次检查状态
				if(!cacheVaild){
					data = ...
					cacheVaild = true;
				}
				//释放写锁之前,降级为读锁
				//降级是可以的
				r.lock();
			}fially{
				//释放写锁
				w.unlock();
			}
		}

		//此处仍然持有读锁
		try{
			use(data);
		}finally{
			r.unlock();
		}
	}
}

2.4StampedLock

Java 在JDK1.8里,提供了一种叫做StampedLock的锁,它的性能比读写锁更好。

2.4.1StampedLock支持的三种锁模式

ReadWriteLock支持两种锁模式:一种是读锁,一种是写锁。而StampedLock支持三种模式,分别是:写锁,悲观读锁和乐观读锁。其中写锁、悲观读锁的语义和ReadWriteLock的写锁和读锁的语义非常的类似,允许多个线程同时获取悲观读锁,但是只允许一个线程读锁,写锁和悲观锁是互斥的。不同的是:StampedLock里的写锁和悲观锁加锁成功之后,都会返回一个stamp;然后解锁的时候,需要传入这个stamp,相关的示例代码如下:

final StampedLock s1 = new StampLock();

//获取/释放悲观读锁实例代码
long stamp = s1.readLock();
try{
	//省略业务代码
}finally{
	s1.unlockRead(stamp);
}

//获取/释放写锁示意代码
long stamp = s1.writeLock();
try{
	//省略业务相关的代码
}finally{
	s1.unlockWrite(stamp);
}

StampedLock的性能之所以比ReadWriteLock还要好,其关键是SteamedLock支持乐观读的方式,ReadWriteLock支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作就会阻塞;而StampedLock提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都会被阻塞的。
注意这里的是“乐观读”这个词,而不是“乐观读锁”,要提醒的你,乐观读这个操作是无锁的,所以想比于ReadWriteLock的读锁,乐观读的性能会好一点。
下面这段代码,在distabceFromOrigin()这个方法中,首先通过调用tryOPtmisticRead()获取了一个Stamp,这里的tryOptimisticRead()就是我们前面提到的乐观读。之后将共享变量x和y读入局部变量中,不过需要注意的是,由于tryOptimisticRead()是无锁的,所以共享变量x和y读入方法局部变量时,x和y有可能被其他的线程修改了。因此最后读完之后,还需要再次验证一次是否存在写操作,这个验证操作时通过调用validate(stamp)来实现的。

class Point{
	private int x,y;
	final StampedLock s1 = new StampedLock();
	//计算到原点的距离
	int distanceFromOrigin(){
		//乐观读
		long stamp = s1.tryOptimisticRead();
		//读入局部变量
		//读的过程数据可能被修改
		int curX = x,curY = y;
		//判断执行读操作期间,是否存在写操作,
		//如果存在,则s1.validate返回false
		if(!s1.validate(stamp)){
			//升级为悲观锁
			stamp = s1.readLock();
			try{
				curX = x;
				curY = y;
			}finally{
				//释放悲观读锁
				s1.unlockRead(stamp);
			}	
		}
		return Math.sqrt(curX * curX + curY * curY);
	}
}

在上面的实例代码中,如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复的执行乐观读,直到执行乐观读操作期间没有写操作(只有这样才能保证x和y的正确性和一致性),而循环读会浪费大量的CPU,升级为悲观读锁,代码简练且不易出错。

2.4.2进一步理解乐观读

如果你曾经用过数据库的乐观锁,可能会发现StampedLock的乐观读和数据库的乐观锁有异曲同工之妙。
所以先介绍一下数据库的乐观锁。想象一下下面的场景。在ERP的生产模块里,会有多个人通过ERP系统提供的UI同时修改同一条生产订单,那如何保证生产订单数据是并发安全的呢?采用乐观所得方案。
乐观锁的实现原理很简单,在生产订单的表product_doc里增加一个数值型版本字段version,每次更新的时候,都会将version字段加1.生产订单的UI在展示的时候,需要查询数据库,此时将这个version字段和其他业务字段一起返回给生产订单UI。假设用户查询的生产订单的id=777,那么sql语句类似于下面这样。
select id, … , version from product_doc where id = 777;

用户在生产订单UI执行保存操作的时候,后台利用下面的SQL语句更新生产订单,此处假设改条生产订单version=9。
update product_doc set version=version,… wher id=777 and version=9;

如果这条SQL语句执行成功并且返回的条数等于1,那么说明从生产订单UI执行查询操作到执行保存操作期间,没有其他人修改过这条数据,因为如果这期间有人修改过这条数据,那么版本字段一定大于9。
你会发现数据库里的乐观锁,查询的时候需要把version字段查询出来,更新的时候需要利用version字段做验证,这个version字段类似于StampedLock里面的stamp,这样对比看,相信能够理解乐观读的原理。

2.4.3StampedLock使用注意事项

对于读多写少的场景StampedLock性能很好,简单的应用场景基本上可以替代ReadWriteLock,但是StampedLock的功能仅仅是ReadWriteLock的子集,在使用的时候,有几点需要注意一下。
StampedLock在命名上并没有加Reentrant,所以你能猜到StampedLock应该是不可重入的。
另外,StampedLock的悲观读锁、写锁都是不支持条件变量的。
还有一点需要注意的是,如果线程阻塞在StampedLock的readLock和writeLock()上时,此时调用该阻塞线程的interrupt()方法,会导致CPU飙升。例如下面的例子,线程T1获取写锁之后将自己阻塞,线程T2尝试获取悲观读锁,也会阻塞;如果此时调用T2线程的interrupt()方法来中断线程T2的话,会导致CPU飙升。

final StampedLock lock = new StampedLock();
Thread T1 = new Thread(() -> {
	//获取写锁
	lock.writeLock();
	//永远阻塞在此处,不释放写锁
	LockSupport.park();
});

T1.start();

//保证T1获取写锁
Thread.sleep(100);
Thread T2 = new Thread(() -> {
	//阻塞在悲观读锁
	lock.readLock();
});
T2.start();

//保证T2阻塞在读锁
Thread.sleep(100);
//中断线程T2
//会导致线程T2所在的CPU飙升
T2.interrupt();
T2.join();


所以使用StampedLock一定不要调用中断操作,如果需要支持中断的话,一定要用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()2.4.4代码模板
final StampedLock s1 = new StampedLock();
//乐观锁
long stamp = s1.tryOptimisticRead();
//读入方法局部变量
....
//校验stamp
if(!s1.validate(stamp)){
	//升级为悲观读锁
	stamp = s1.readLock();
	try{
		//读入方法局部变量
		...
	}finally{
		//释放悲观读锁
		s1.unlockRead(stamp);
	}
}

//使用方法局部变量执行业务操作

2.5CountDownLatch和CyclicBarrier

实现一个对账系统,对账系统的逻辑很简单。需求,用户通过在线商城下单,会生成电子订单,保存在订单库;之后物流会生成派送单给用户发货,派送单保存在派送单库。为了防止漏派送和重复派送,对系统每天还会校验是否存在异常订单。
下图为系统对账图。
在这里插入图片描述
对账系统的代码抽象之后,也很简单。核心代码如下,就是一个单线程里面循环查询订单和派送单,然后执行对账,最后写入差异库。

while(存在未对账订单){
	//查询为对账订单
	pos = getPOrders();
	//查询派送订单
	dos = getDOrders();
	//执行对账操作
	diff=check(pos,dos);
	//差异写入差异库
	save(diff);
}

2.5.1利用并行优化对账系统

目前的对账系统,由于订单量和派送单量巨大,所以查询对账订单geyPOrders()和查询派送单getDOrders()相对较慢,且目前对账系统是单线程的,图形化是下图这个样子,对于串行化的系统。
在这里插入图片描述
可以让查询未对账的订单和查询派送单并行处理,执行过程如下图所示。
在这里插入图片描述
下面是代码的实现,下面的代码中,我们创建了两个线程T1和T2,并行执行查询未对账订单getPOrders()和查询派送单getDOrders()这两个操作,在主线程中执行对账操作check()和差异保存save()两个操作。不过需要注意的是:主线程需要等待线程T1和T2执行完才能执行check()和save()这两个操作,为此我们通过调用T1.join和T2.join()来实现等待,当T1和T2线程退出,调用T1.join()和T2.join()的主线程就会从阻塞态被唤醒,从而执行之后的check()和save()。

while(存在未对账订单){
	//查询对账订单
	Thread T1 = new Thread(() -> {
		pos = getPOreders();
	});
	T1.start();
	//查询派送单
	Thread T2 = new Thread(() -> {
		dos = getDOrders();
	});
	T2.start();
	//等待T1和T2结束
	T1.join();
	T2.join();
	//指向对账操作
	diff = check(pos,dos);
	//差异写入差异库
	save(diff);
}

2.5.2用CountDownLatch实现线程等待

上面的程序存在不足,因为每次while循环里面都会创建新的线程,而创建新的线程是个耗时的操作,所以最好是创建出来的线程可以重复利用,可以使用线程池。
下面的代码优化后:我们首先创建一个固定大小为2的线程池,之后的while循环里重复利用。但是有个问题,那就是如何知道getPOrders()和getDOrders()这两个操作什么时候执行完。前面主线程通过调用线程T1和T2的join()方法来等待线程T1和T2的退出,但是线程池方案里,线程根本就不会退出,所有Join的方法失效了。

//创建2个线程的线程
Executor executor = Executors.newFixedThreadPool(2);
while(存在未对账订单){
	//查询未对账订单
	executor.execute(() -> {
		pos = getPOrders();
	});
	//查询派送订单
	executor.execute(() -> {
		dos = getDOrders();
	});

	/* ?? 如何实现等待 ?? */
	diff =  check(pos.dos);
	//差异写入差异库
	save(diff);
}

那如何解决这个问题呢?最直接的方式就是使用一个计数器,初始值设置为2,当执行完pos=getPOrder();这个操作之后将计数器减1,执行完dos=getDOrders();之后也将计数器减1,在主线程中,等待计数器等于0.;当计数器等于0,时,说明这两个查询操作执行完了。等待计数器等于0其实是一个条件变量,用管程实现起来也很简单。
Java中已经提供了实现类似功能的工具类:CountDownLatch。

//创建2个线程的线程
Executor executor = Executors.newFixedThreadPool(2);
while(存在未对账订单){
	//计数器初始化为2
	CountDownLatch latch = new CountDownLatch(2);
	//查询未对账订单
	executor.execute(() -> {
		pos = getPOrders();
		latch.countDown();
	});
	//查询派送订单
	executor.execute(() -> {
		dos = getDOrders();
		latch.countDown();
	});

	//等待两个查询操作结束
	latch.await();
	
	diff =  check(pos.dos);
	//差异写入差异库
	save(diff);
}

2.5.3进一步优化性能

前面我们将getPOrders()和getDOrders()这两个查询操作并行了,但是这两个操作和对账操作check()、save()之间是串行的。很显然,这两个查询操作和对账操作也是可以并行的,也就是说,在执行对账操作的时候,可以同时去执行下一轮的查询操作,如下图所示。
在这里插入图片描述
那接下来如何实现这步优化呢,两次查询操作和对账操作并行,对账操作还依赖查询操作的结果,这明显是生产者和消费者的意思,两次查询操作时生产者,对账操作是消费者。既然是生产者-消费者模型,那就需要队列,来保存生产者生产者的数据,而消费者则从这个队列消费数据。
不过针对对账这个项目,设计了两个队列,并且两个队列的元素之间还有对应的关系,具体如下图所示,订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送单插入到派送单的队列,这两个队列的元素之间是有一一对应的关系的。这两个队列的好处是,对账操作可以每次从订单出一个元素,从派送单队列出一个元素,然后这两个元素执行对账操作,这样数据一定不会乱掉。
在这里插入图片描述
下面再来看如何用用双队列实现完全的并行,一个最直接的想法是:一个线程T1执行订单的查询操作,一个线程执行线程T2执行派送单的查询工作,当线程T1和T2都各自生产完一条数据的时候,通知线程T3执行对账的操作。这个想法看起来很简单,但是隐藏着一个条件,那就是线程T1和T2步调要一致,不能一个跑的太快,一个跑的太慢,只有这样才能做到各自生产完1条数据的时候,通知线程T3。
下面这幅图形象的描述了上面的意图:线程T1和线程T2只有都生产完1条数据的时候,才能一起向下执行,也就是说线程T1和线程T2要相互等待,步调一致;同时当线程T1和T2都生产完一个数据的时候,还要能够通知线程T3执行对账操作。
在这里插入图片描述

2.5.4用CycliBarrier实现线程同步

下面我们就来实现上面提到的方案,这个方案的的难点有两个:一个是线程T1和T2要做到步调一致,另外一个是要能够通知到线程T3。
Java的并发包中CyclicBarrier,我们创建一个计数器初始值为2的CyclicBarrier,你需要注意的是创建CyclicBarrier的时候,我们还需要传入一个回调函数,当计数器减到0 的时候,会调用这个回调函数。
线程T1负责查询订单,当查询出一条的时候,调用barrier.await()来将计数器减1,同时等待将计数器变为0;线程T2负责查询派送单,当查询一条时,也调用barrier.await()来将计数器减1,同时等待计数器变成0;当线程T1和T2完成之后,计数器减为0,此时T1和T2就可以执行下一条语句了,同时会用barrier的回调函数来执行对账操作。
而且,CyclicBarrier的计算器还有自动重置的功能,单减为0的时候,会自动重置你设置的初始值。

//订单队列
Vector<P> pos;
//派送单队列
Vector<D> dos
//执行回调的线程池
Executor executor = Executors.newFixedThreadPool(1);
final CyclicBarrier barrier = new CyclicBarrier(2,()->{
	executor.execute(() -> check());
});

void check(){
	P p = pos.remove(0);
	D d =dos.remove(0);
	//执行对账操作
	diff = check(p,d);
	//差异写入差异库
	save(diff);
}

void checkAll(){
	//循环查询订单库
	Thread T1 = new Thread(() -> {
		while(存在未对账订单){
			//查询订单库
			pos.add(getPOrder());
			//等待
			barrier.await();
		}
	});
	T1.start();
	//循环查询运单库
	Thread T2 = new Thread(() -> {
		while(存在未对账订单){
			//查询运单库
			dos.add(getOrders());
			//等待
			barrier.await();
		}
	});
	T2.start();
}

2.6并发容器

Java并发包中有一大部分内容是关于并发容器的。
Java1.5 之前提供的同步容器虽然也能保证线程安全,但是性能差,而java1.5之后提供的并发容器在性能方面做了很多优化,并且容器的类型更加丰富了。
同步容器及其注意事项
Java中容器主要分为四个大类,分别为List、Map、Set和Queue,但是并不是所有的Java容器都是线程安全的。例如,我们常用的ArrayList、HashMap就不是线程安全的。在介绍线程安全的容器之前,我们先思考一个问题:如何将非线程安全的容器变成线程安全的容器呢?
其实实现思路很简单,只要把非线程安全的容器封装在对象内部,然后控制好访问路径就可以了。
下面以ArrayList为例,看看如何将它变成安全的。在下面的代码中,SafeArrayList内部持有一个ArrayList的实例c,所有访问c的方法我们都增加了synchronized关键字,需要注意的是我们还增加了一个addIfNotExist()方法,这个方法也是用synchronized来保证原子性的。
看到这里,你可能想到:所有非线程安全的类是不是都可以用这种包装的方式来实现线程安全呢。所以在Collections这个类中还提供了一套完备的包装类,比如下面的示例代码中,分别把ArrayList、HashSet和HashMap包装成了线程安全的List、Set和Map。
之前多次强调,组合操作需要注意竞态条件问题,例如上面提到的addIfNotExist()方法就包含组合操作。组合操作往往隐藏着静态条件问题,即便每个操作都能保证原子性,也不能保证组合操作的原子性,这个一定要注意。
在容器领域一个容易被忽视的“坑”是用迭代器遍历容器,例如在下面的代码中,通过迭代器遍历容器list,对每个元素调用foo()方法,这就存在并发问题,这些组合不具备原子性。

List list = Collections.sychronizedList(new ArrayList());
Iterator i = list.iterator();
while(i.hasnext())
	foo(i.next());

而正确的做法是下面这样的,锁住list之后再执行遍历操作,如果你查看Collections内部的包装类源码,你会发现包装类的公共方法锁的是对象this,其实是我们这里的list,所以锁住list是绝对安全的。

List list = Collections.sychronizedList(new ArrayList());
Iterator i = list.iterator();
synchronized(this){
	while(i.hasnext())
		foo(i.next());
}	

上面我们提到的这些经过包装后的线程安全容器,都是基于synchronized这个同步关键字实现的,所以也被称为同步容器。Java提供的同步容器还有Vector、Stack和HashTable,这三个类不是基于包装类实现的,但是基于synchronized实现的,这三个容器的遍历,同样要加锁保证互斥。

2.6.1并发容器及其注意事项

Java在1.5版本之前所谓的线程安全的容器,主要指的是同步容器。不过同步容器有个最大的问题,那就是性能差,所有方法都是用synchronized来保证互斥的,串行度太高了。因此Java在1.5之后提供了性能更高的容器,我们一般称为并发容器。
并发容器虽然数量非常多,但依然是我们提到的四大类:List、Map、Set和Queue,下面的并发容器关系图上
在这里插入图片描述
1.List
List里面只有一个实现类就是CopyOnWriteArrayList。CopyOnWrite,顾名思义就是写的时候会将共享变量新复制一份出来,这样的做的好处是完全无锁。
那CopyOnWriteArrayList的实现原理是怎样的呢?
CopyOnWriteArrayList内部维护了一个数组,成员变量array就指向这个内部数组,所有读操作都是基于Array进行的,如下图所示,迭代器Iterator遍历的就是Array数组。
在这里插入图片描述
如果遍历Array的同时,还有一个写操作,例如增加元素,CopyOnWriteArrayList是如何处理的呢?CopyOnWriteArrayList会将array复制一份,然后在新复制处理的数组上执行增加的操作,执行完之后,再将Array指向新数组。通过下图你可以看到,读写是可以并行的,遍历操作一直都是基于原array执行,而写操作则是基于新array进行。
使用CopyOnWriteArrayList需要注意的坑主要有两方面。一个是应用场景,CopyOnWriteArrayList仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致,例如上面的例子中,写入的新元素并不能立刻遍历到。另外一个需要注意的是,CopyOnWriteArrayList迭代器都是只读的,不支持增删改。因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的。
2.Map
Map接口的两个实现是ConcurrentHashMap和ConcurrentSkipListMap,它们从应用的角度来看,区别在于ConcurrentHashMap的key是无序的,而ConcurrentSkipListMap是有序的,所以如果你需要保证key的顺序,就只能使用ConcurrentSkipListMap。
使用ConcurrentHashMap和ConcurrentSkipListMap需要注意的地方是,它们的key和value都不能为空,否则会抛出NullPoniterException这个运行时异常。下面这个表格总结了Map相关的实现类对于key和value的要求。
ConcurrentHashMap里面的SkipList本身就是一个数据结构,它的插入、删除、查询操作平均时间复杂度是O(logn),理论上和并发线程数没有关系,所以在并发度非常高的情况下,若你对ConcurrentHashMap的性能不满意,可以尝试一下ConcurrentSkipListMap。
3.Set
Set接口的两个实现类是CopyOnWriteArraySet和ConcurrentSkipListSet。
4.Queue
Java并发包里面Queue这类并发容器是最复杂的,可以从以下两个维度来分类。一个维度是阻塞与非阻塞,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。另外一个维度是单端和双端,单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。Java并发包里阻塞队列都是用Blocking关键字标识的,单端队列使用Queue标识,双端队列使用Dequeue标识。
这两个维度组合后,可以将Queue组分为四大类,分别是:
① 单端阻塞队列
其实有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue,内部一般会持有一个队列,这个队列可以是数组(其实现是ArrrayBlockingQueue)也可以是链表(其实现是LinkedBlockingQueue);甚至还可以不持有队列(其实是SynchronousQueue),此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合LinkedBlockingQueue和SynchronousQueue的功能,性能比LinkedBlockingQueue好;PriorityBlockingQueue支持按照优先级出队;DelayQueue支持超时出队。
1)双端阻塞队列:其实实现是LinkedBlockingQueue。
2)单端非阻塞队列:其实现是ConcurrentLinkedQueue。
3)双端非阻塞队列:其实现是ConcurrentLinkedDeque。

另外,使用队列时,需要格外注意队列是否支持有界(所谓有界指的是内部队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数量大了之后很容易导致OMM。上面我们提到这些Queue中,只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的,所以在使用其他误解队列时,一定要充分考虑是否存在导致OMM的隐患。

未完待续。。。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值