今天更新的主题是java中的锁机制:第一章(大纲1-4)。
由于Java锁的东西比较多,我准备分三章来讲解。
很多人都只知道锁的一些概念,也能讲出来一二三四,但是我在面试别人的时候,一问:讲讲java中的同步,可能就只能回答出来synchronized,单例场景等。为了避免这种尴尬,今天我将通过例子,带大家逐步认识Java中的锁与应用场景。只要认真读完,我相信对各位不管是工作还是面试,都会有比较大的帮助。
大纲:
- 1. 并发的特性
- 2. 锁的分类
- 3. synchronized
- 4. volatile
- 5. Lock
- 6. ThreadLocal
- 7. Atmoic
- 8. Semaphore
- 9. 阻塞队列
- 10. 死锁
- 11. CountdownLatch
- 12.CyclicBarrier
前言:为什么要使用同步?
Java允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据的增删改查),将会导致数据不准确性,相互之间产生冲突。因此加入同步锁,避免在该线程没有完成工作之前,被其他线程调用,从而保证该变量的唯一性和准确性。
1. 并发的特性
并发的特性为:原子性,有序性和可见性。
- 1.1 何为原子性?
原子性指一个操作是不可中断的,在多线程场景下,一个原子操作一旦开始,就不会被其他线程干扰破坏。
那么在Java中,哪些操作是原子的呢?
对基本类型的操作,除了long和double之外
所有引用reference的赋值操作
java.concurrent.Atomic.* 包中所有类的一切操作
在32位机器的上,对long和double的操作是非原子性的,是因为long和double都占8个字节,64位。
在32位的操作系统上对64位的数据读写需要分两步完成,每一步取32位数据。这样对double和long的赋值操作就会有问题:如果有两个线程同时写一个变量内存,一个进程写低32位,而另一个写高32位,这样将导致获取的64位数据是失效的数据。因此需要使用volatile关键字来防止此类现象,volatile本身不保证获取和设置操作的原子性,仅仅保持修改的可见性。但是java的内存模型保证声明为volatile的long和double变量的get和set操作是原子的(其他操作不会,如+-*/,volatile下面的目录会有讲解)。
Tips:
由于Java的跨平台性,基本数据类型在32位和64位机器上占用的字节数是一样的,不用像C/C++那样做平台适配。
64位CPU拥有更大的寻址能力,最大支持到16GB内存,而32bit只支持4G内存 。64位CPU一次可提取64位数据,比32位提高了一倍,理论上性能会提升1倍。
- 1.2 何为有序性?
有序性是指程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,允许编译器和处理器对指令进行重排序。在单线程场景下,重排序不会影响程序的执行,但是在多线程并发场景下,却会影响程序执行的正确性。(例如:重排的时候某些赋值会被提前)
在Java里面,可以通过volatile关键字来保证一定的"有序性"。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
Tips:
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU。
int a = 1; boolean flag = true;
假如不是a = 1的操作,而是
a = new byte[1024*1024]
(分配1M空间),那么它会运行地很慢,此时CPU是等待其执行结束呢,还是先执行下面的语句flag=true呢?显然,先执行flag=true可以提前使用CPU,加快整体效率,当然这样的前提是不会产生错误。虽然这里有两种情况:后面的代码先于前面的代码开始执行;前面的代码先开始执行,但当效率较慢的时候,后面的代码开始执行并先于前面的代码执行结束。不管谁先开始,总之后面的代码在一些情况下存在先结束的可能。
在硬件层面,CPU会将接收到的一批指令按照其规则重排序,同样是基于CPU速度比缓存速度快的原因,和上一点的目的类似,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机可以在更大层面、更多指令范围内重排序。
- 1.3 何为可见性?
可见性是指多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够马上看到修改的值。
Java提供了volatile关键字来保证可见性,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性。
2. 锁的分类
公平锁/非公平锁
可重入锁
独享锁/共享锁
互斥锁/读写锁
乐观锁/悲观锁
分段锁
偏向锁/轻量级锁/重量级锁
自旋锁
这些分类,并不是全指锁的状态,有的是指锁的特性或者锁的设计。
- 2.1 公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取到锁。
Java中的ReentrantLock,可以通过构造器指定该锁是否公平,默认是非公平的,非公平锁的优势在于吞吐量比公平锁大。而Java中的synchronized,是一种非公平锁,它并不像ReentrantLock一样,通过AQS来实现线程调度,所以没有方法让它变成公平锁。
- 2.2 可重入锁
可重入锁又叫做递归锁,指的是同一个线程在外层方法里获取到了某个锁,进入到该方法里的内层方法会自动获取到该锁,这样可以避免死锁问题。
Java中的synchronized和ReentrantLock都是可重入锁。
public static void main(String[] args) {
// 演示可重入锁
TestReentrantLock testReentrantLock = new TestReentrantLock();
testReentrantLock.functionA();
}
private static class TestReentrantLock {
public synchronized void functionA() {
System.out.println("functionA");
// 如果synchronized不是可重入锁,将直接死锁
functionB();
}
public synchronized void functionB() {
System.out.println("functionB");
}
}
代码输出:
functionA
functionB
- 2.2 独享锁/共享锁
独享锁是指该锁一次只能被一个线程持有,而共享锁是指该锁一次可以被多个线程持有。
synchronized毫无疑问是独享锁,Lock类的实现ReentrantLock也是独享锁,而Lock类的另一个实现ReadWriteLock,它的读锁是共享的(可以让多个线程同时持有,提高读的效率),而它的写锁是独享的。ReadWriteLock的读写,写读,写写的过程是互斥的(后面的Lock目录会详细讲解)。
- 2.3 互斥锁/读写锁
上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
互斥锁在Java中的具体实现就是ReentrantLock,包括
synchronized。
读写锁在Java中的具体实现就是ReadWriteLock。
- 2.4 乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
悲观锁认为对于同一个数据的并发操作,一定是会发生修改,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
从上面的描述可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
在Java中利用各种锁,其实就是悲观锁的使用。
而乐观锁在Java中的使用,则是无锁编程,常常采用的是CAS算法,典型的例子就是原子类(比如AtomicBoolean, AtomicInteger),通过CAS自旋实现原子操作的更新。
- 2.5 分段锁
这是从锁的设计来分的,细化锁的粒度,而不是一有操作就锁住整个对象。
举两个例子:
(1) JDK中的HashTable
/**
* Returns the number of keys in this hashtable.
*
* @return the number of keys in this hashtable.
*/
public synchronized int size() {
return count;
}
/**
* Tests if this hashtable maps no keys to values.
*
* @return <code>true</code> if this hashtable maps no keys to values;
* <code>false</code> otherwise.
*/
public synchronized boolean isEmpty() {
return count == 0;
}
HashTable的所有函数都是用synchronized,用的同一把锁,就是当前的HashTable对象,可想而知它的效率能高到哪儿去。
(2) JDK中的ConcurrentHashMap
/**
* Stripped-down version of helper class used in previous version,
* declared for the sake of serialization compatibility
*/
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
ConcurrentHashMap
中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
Tips:
后面会单开一章节分析
ConcurrentHashMap的原理
- 2.6 偏向锁/轻量级锁/重量级锁
这三种锁是从锁的状态来划分的,而且是针对synchronized。
在Java 5通过引入锁升级的机制来实现高效Synchronized,
这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
- 2.7 自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
private static class SpinLock {
private AtomicReference<Thread> sign = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
while (!sign.compareAndSet(null, current)) {
}
}
public void unlock() {
Thread current = Thread.currentThread();
sign.compareAndSet(current, null);
}
}
3. synchronized
synchronized是用来控制线程同步用的,可以用来修饰方法或者代码块。被synchronized修饰的方法或者代码块,在多线程场景下,不会被多个线程同时执行。
- 3.1 synchronized修饰方法
public synchronized void test01() {
System.out.println("test01");
}
注意,synchronized锁住的是对象而不是代码段,上面代码synchronized锁住的就是TestSynchronized的this对象。
- 3.2 synchronized修饰代码块
public void test02() {
synchronized (this) {
System.out.println("test02");
}
}
test02方法中就有synchronized代码块,此时锁住的也是TestSynchronized的this对象。
- 3.3 自定义对象锁
private final Object object = new Object();
public void test04() {
synchronized (object) {
System.out.println("test04");
}
}
这种方式的好处类似ConcurrentHashMap,对不同的操作加不同的锁,可以提高多线程场景下的吞吐量。但是缺点是需要新创建锁对象,造成一定的开销。(JDK源码中有大量这种实现方式)
- 3.4 静态synchronized方法
public static synchronized void test03() {
System.out.println("test03");
}
test03()是一个静态synchronized函数,锁住的是TestSynchronized类对象,称为类锁,test01, test02锁称为对象锁。
类锁和对象锁不会相互影响,因为不是同一把锁。
我们来看看下面程序的执行结果:
private static class TestSynchronized {
public synchronized void test01() {
System.out.println("test01 start");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("test01 end");
}
public void test02() {
synchronized (this) {
System.out.println("test02 start");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("test02 end");
}
}
public static synchronized void test03() {
System.out.println("test03 start");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("test03 end");
}
}
public static void main(String[] args) {
final TestSynchronized testSynchronized = new TestSynchronized();
new Thread(new Runnable() {
@Override
public void run() {
testSynchronized.test01();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
testSynchronized.test02();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
TestSynchronized.test03();
}
}).start();
}
输出:
test01 start
test03 start
test01 end
test03 end
test02 start
test02 end
分析:test01和test02共用一把锁,就是TestSynchronized的一个实例对象,而test03使用的则是TestSynchronized的类对象作为锁。由于test01先获取到对象锁,然后休眠2s,所以test02必须等test01休眠执行完后才能拿到锁,而test03却没有受到影响。
- 3.5 单例模式使用synchronized
public class Singleton {
private volatile static Singleton singleton;
private Singleton () {
}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
以上是最好的写法,具体可以参考这篇文章:
简单来说:双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null
,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。
- 3.6 i++场景需要使用synchronized修饰吗?
答案是肯定的。i++不是原子操作,它其实分为了三步,第一步:读取i的值,第二步,i + 1,第三步:将 i + 1计算的值重新赋值给i。
来看一个例子,不加锁的情况:
private static final class TestSynchronized {
private int i;
public void increase() {
i++;
System.out.println(Thread.currentThread().getName() + " increase i = " + i);
}
}
public static void main(String[] args) {
final TestSynchronized testSynchronized = new TestSynchronized();
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
testSynchronized.increase();
}
}, "thread - " + i).start();
}
}
执行后输出:
thread - 0 increase i = 2
thread - 3 increase i = 4
thread - 2 increase i = 3
thread - 1 increase i = 2
thread - 4 increase i = 5
很明显结果乱了,不符合正常逻辑。
然后我们在i++操作上加上同步:
public void increase() {
synchronized (this) {
i++;
}
System.out.println(Thread.currentThread().getName() + " increase i = " + i);
}
执行输出:
thread - 0 increase i = 1
thread - 2 increase i = 3
thread - 1 increase i = 2
thread - 3 increase i = 4
thread - 4 increase i = 5
可以看到,五个线程的结果分别是:1, 2, 3, 4, 5(顺序不一定,依靠CPU调度)。
我们再拓展下,什么时候读需要加锁?
比如我银行转账,先获取余额,如果余额 = 0,则转账失败。这个时候,如果多线程场景下去拿余额,然后再以余额为条件或者对余额进行操作,那么读这个操作是需要加锁的。
比如我想知道在对i++之前,i的值是多少:
private static final class TestSynchronized {
private int i;
public void increase() {
synchronized (this) {
System.out.println(Thread.currentThread().getName() + " i want know current i = " + i);
i++;
System.out.println(Thread.currentThread().getName() + " after increase i = " + i);
}
}
}
public static void main(String[] args) {
final TestSynchronized testSynchronized = new TestSynchronized();
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
testSynchronized.increase();
}
}, "thread - " + i).start();
}
}
这个时候,拿i的值也是需要加锁的。
执行输出:
thread - 0 i want know current i = 0
thread - 0 after increase i = 1
thread - 4 i want know current i = 1
thread - 4 after increase i = 2
thread - 3 i want know current i = 2
thread - 3 after increase i = 3
thread - 2 i want know current i = 3
thread - 2 after increase i = 4
thread - 1 i want know current i = 4
thread - 1 after increase i = 5
上面对i++的同步操作,除了使用synchronized,还可以使用Lock,AtomicInteger。
synchronized总结:
1. 尽量缩小锁的范围,只需要在必须加锁的代码范围内加锁;
2. 锁的代码内尽量不要做耗时操作,避免其他线程长时间等待锁;
3. 只是读取不需要加锁;
4. 如果只是读操作,没有写操作,则可以不用加锁,此种情形下,变量加上final关键字;
5. 如果有写操作,但是变量的写操作跟当前的值无关联,且与其他的变量也无关联,则可考虑变量加上volatile关键字,同时写操作方法通过synchronized加锁;
6. 如果有写操作,且写操作依赖变量的当前值(如:i++),则getXXX和写操作方法都要通过synchronized加锁。
4. volatile
前面这么长的内容中,出现过volatile的身影,现在我来带大家好好了解下这哥们。
并发的特性为:原子性,有序性和可见性。
- 4.1 volatile的定义
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。
先看一段代码,假如线程1先执行,线程2后执行:
private static final class TestVolatile {
private boolean stop = false;
public void testVolatile01() {
while (!stop) {
System.out.println("testVolatile01...");
}
}
public void testVolatile02() {
stop = true;
}
}
public static void main(String[] args) {
final TestVolatile testVolatile = new TestVolatile();
Thread thread0 = new Thread(new Runnable() {
@Override
public void run() {
testVolatile.testVolatile01();
}
});
thread0.start();
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
testVolatile.testVolatile02();
}
});
thread1.start();
}
执行输出:
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
可以看到可以停止掉线程执行,但是stop不会里面写入到主存,所以执行逻辑有延迟。
private volatile boolean stop = false;
stop标记加上volatile修饰,再执行:
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
testVolatile01...
很明显。
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程1读取到的就是最新的正确的值。
- 4.2 volatile保证原子性吗?
从上面知道volatile关键字保证了操作的可见性,但是volatile能保证对变量的操作是原子性吗?
private static final class TestVolatile2 {
private volatile int count;
public void increase() {
count++;
}
}
public static void main(String[] args) {
final TestVolatile2 testVolatile2 = new TestVolatile2();
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++)
testVolatile2.increase();
countDownLatch.countDown();
}
}.start();
}
// 等待所有线程都执行完
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(testVolatile2.count);
}
预期结果应该是输出10000,但是最后每次允许输出的结果值都小于10000。
根源就是自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
修改成这样就行了:
public void increase() {
synchronized(this) {
count++;
}
}
那如果是原子操作呢?答案是原子操作不需要加锁,但是在32位机器的上,对long和double的操作是非原子性的,具体看前面讲的原子性概念。
private static final class TestVolatile2 {
private volatile int count;
public void setCount(int count) {
this.count = count;
}
}
public static void main(String[] args) {
final TestVolatile2 testVolatile2 = new TestVolatile2();
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
final int temp = i;
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++)
testVolatile2.setCount(temp * j);
countDownLatch.countDown();
}
}.start();
}
// 等待所有线程都执行完
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(testVolatile2.count);
}
输出的结果一直是:8991
总结下volatile的使用场景:
1. 状态标记量。
2. double check。(如单例模式)
3. 独立观察。
4. volatile bean 模式。
前两个用的比较多,具体的可以参考: