思维导图:

从应用程序的角度来看,线程安全问题的产生是由于多线程应用程序缺乏某种东西——线程同步机制。
线程同步机制是一套用于协调线程间的数据访问及活动的机制,该机制用于保障线程安全以及实现这些线程的共同目标。
【举个例子】
如果把线程比作在公路上的行驶的车辆,那么线程同步机制就好比是任何车辆都要遵循的交通规则。公路上行驶的车辆只有遵守交通规则才能够达到其目的——安全地到达目的地。
Java平台提供的线程同步机制包括:
我们知道线程安全问题的产生前提是多个线程并发访问共享变量、共享资源。
我们就很容易想到一种保障线程安全的方法——将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。
锁(LOCK)就是利用这种思路以保障线程安全的线程同步机制。
按照Java虚拟机对锁的实现方式划分:Java平台中的锁包括内部锁和显式锁。
synchronized关键字实现java.councurrent.locks.Lock接口的实现类(如java.concurrent.locks.ReentrantLock类)实现的。锁能够保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。
锁是通过互斥保障原子性的。
互斥:就是指一个锁一次只能被一个线程持有。synchronized就是通过互斥方式,保证了临界区代码一次只能够被访问一个线程执行。
因此,一个线程执行临界区期间没有其他线程能够访问相应的共享数据,这使得临界区代码所执行的操作自然而然第具有不可分割的特性,即具备了原子性。
从互斥的角度来看,锁其实是将多个线程对共享数据的访问由本来的并发(未使用锁的情况下)改为串行(使用锁之后)。因此,虽然实现并发是多线程编程的目标,但是这种并发往往是并发中带有串行的局部并发。就好比公路维修使得多股车道在某处被合并成一股小车道,从而使原本来多股车道上并驾齐驱的车辆不得不鱼贯而行。
可见性的保障是通过写线程 冲刷处理器缓存和读线程 刷新处理器缓存这两个动作实现的。
在Java平台中,
刷新处理器缓存这个动作,这使得读线程在执行临界区代码前(获得锁之后)可以将写线程对共享变量所做的更新,同步到该线程执行处理器的高速缓存中(即可更新同步,符合:读线程刷新处理器缓存)。冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被“推送”到该线程执行处理器的高速缓存中,从而对读线程可同步。因此,锁能够保障可见性。
锁的互斥性及其对可见性的保障合在一起。可保证临界区内的代码能够读取到共享数据的最新值。
因此,线程在临界区中所读取到共享数据的相对新值(锁对保障可见性的结果)同时也是最新值。
锁能够保障有序性。
写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即读线程对这些操作的感知顺序与源代码顺序一致。这是锁对原子性和可见性的保障的结果。
设写线程在临界区中更新了b、c和flag 这3个共享变量,
b = a + 1;
c = 2;
flag = true;
可见性的保障,写线程在临界区中对上述任何一个共享变量锁做的更新都对读线程可见。原子性,因此写线程对上述共享变量的更新会同时对读线程可见,即在读线程看来这些变量就像是同一时刻被更新的。因此读线程 并无法(也没有必要)区分写线程实际上是以什么顺序更新以上变量的,这就意味着读线程可以认为写线程是依照源代码顺序更新上述共享变量的,即有序性得以保障。
由于锁能够保障有序性,因此对于上面例子,可几种情况(读线程无法区分更新顺序):
因此:尽管锁能够保障有序性,但是这并不意味着临界区内的内存操作不能够被重排序。
临界区内的任意两个操作依然可以在临界区之内被重排序(即不会重排序到临界区之外)。
由于临界区内的操作具有原子性,写线程在临界区内对各个共享数据的更新同时对读线程可见,因此这种重排序并不会对其他线程产生影响。
需要注意锁对可见性、原子性和有序性的保障是有条件的,我们要同时保障以下两点得以满足:
仅是读取这组共享数据而没有对其进行写更新的话,也需要在读取时持有相应的锁。上述任意一个条件未满足都会使原子性、可见性和有序性没有保障。
因此,访问同一组共享数据的多个线程必须同步在同一个锁实例上,并且即使是仅仅对共享数据进行读取的访问也要加锁。
可重入性(Reentrancy) 描述这样一个问题:一个线程在其持有一个锁的时候能否再次(或者多次)申请该锁。如果一个线程持有一个锁的时候还能够继续成功申请该锁,那么我们就称该锁是可重入的(Reentrant),否则我们就称该锁为非可重入的(Non-reentrant)。
可重入性问题的由来可以通过伪代码理解:
void methodA(){
acquireLock(Lock); // 申请锁lock
//省略....
methodB(); // 再次调用methodB ,申请锁lock
releaseLock(lock); // 释放锁
}
void methodB(){
acquireLock(lock); // 申请锁Lock
//...
releaseLock(lock); // 释放锁Lock
}
方法A使用了锁lock, 该锁引导的临界区代码又调用了另外 一个方法B, 而方法B也使用lock。
那么这就产生了一个问题:方法A 的执行线程持有锁lock 的时候调用了 方法B, 而方法B执行的时候又去申请锁lock , 而lock 此时正被当前线程持有(未被释放)。那么,此时方法B究竟能否获得(申请成功)lock 呢?可重入性就描述了这样一个问题。
可重入锁可以被理解为一个对象,该对象包含一个计数器属性。计算器属性的初始值为0,表示相应的锁还没有被任何线程持有。
一个可重入锁的持有线程初次获得该锁时相应的开销相对大,这是因为该锁的持有线程必须与其他线程“竞争” 以获得锁。
可重入性锁的持有线程继续获得相应锁所产生的开销要小的多,这是因为此时Java虚拟机只需要将相应的锁的计数器属性值加1 , 即可实现锁的获得。
锁可以被看作多线程程序访问共享 数据时所需持有的一种排他性资源。因此资源的争用、调度的概念对锁也是适用的。
Java平台中锁的调度策略包括
相应的锁就被称为
内部锁属于非公平锁,而显示锁则既支持公平锁也支持非公平锁。
一个锁实例可以保护一个或者多个共享数据。一个锁实例所保护的共享数据的数量大小就称为锁的粒度
锁可能导致上下文切换。
锁的不正确使用会导致一些线程活性故障:
如果其中有线程设计如下操作,那么我们可以考虑使用锁: