五、锁的深入化
锁是并发编程共享数据,保证数据一致性的工具。在Java中有多种实现,例如synchronized(重量级锁)、ReentrantLock(轻量级锁)等,这些锁为我们的来发提供了便利。下面我跟大家聊一聊Java中锁的相关知识。
1.重入锁
重入锁的概念:重入锁也叫递归锁。就是说同一线程中,外层函数获取了锁,可以传递给内层函数去使用,可重入性可以避险死锁现象。synchronized(重量级锁)、ReentrantLock(轻量级锁)都属于重入锁。下面写一个可重入锁的例子:
class Test implements Runnable{
@Override
public void run() {
set();
}
//synchronized要在代码块执行完毕后才会释放锁
public synchronized void set(){
System.out.println("set方法");
get();
}
public synchronized void get(){
System.out.println("get方法");
}
}
//synchronized(重量级) 和Lock锁(轻量级)——重入锁(具有递归性)
public class test01 {
public static void main(String[] args) {
Test test = new Test();
Thread t1 = new Thread(test);
t1.start();
}
}
在这个例子中,set方法和get方法的锁是同一个,在这里我们假设synchronized锁不具有可重入性,那么get方法就必须要等待set方法释放锁后才能获取锁,这样在set方法中调用get方法必然会造成死锁现象(get方法一直在等待set方法执行完毕)。但是上面的代码并没有出错,说明synchronized锁具有可重入性,set方法中调用get方法,将set方法获取的锁传递给内层函数(get)。
2.读写锁
假设程序中涉及到对一些共享资源的读写操作,并且在没有做写入操作是,允许两个线程同时读入资源。这时就需要用到读写锁。读写锁允许多个线程同时读取资源,但是不允许多个线程同时进行写入操作或者同时读写操作,也就是说:读-读能共存,读-写不能共存,写-写不能共存。下面写一个读写锁的例子:
package com.zhu.test;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock;
public class test03 {
private volatile Map<String,String> caChe = new HashMap<>();
//新建一个读写锁
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
//写锁
private WriteLock writeLock = rwl.writeLock();
//读锁
private ReadLock readLock = rwl.readLock();
//写入
public void put(String key,String value){
try {
writeLock.lock();
System.out.println("写入put方法key:" + key + ",value:" + value + "开始");
//Thread.sleep(50);
caChe.put(key, value);
System.out.println("写入put方法key:" + key + ",value:" + value + "结束");
} catch (Exception e) {
// TODO: handle exception
}finally {
writeLock.unlock();
}
}
//读取
public String get(String key){
try {
readLock.lock();
String value = caChe.get(key);
System.out.println("读取get方法key:" + key + ",value:" + value + "开始");
Thread.sleep(50);
caChe.put(key, value);
System.out.println("读取get方法key:" + key + ",value:" + value + "结束");
return value;
} catch (Exception e) {
// TODO: handle exception
}finally {
readLock.unlock();
}
return null;
}
public static void main(String[] args) {
test03 t = new test03();
//写入线程
Thread write = new Thread(new Runnable() {
public void run() {
for(int i = 0;i < 10;i++){
t.put("i", i+"");
}
}
});
//读取线程
Thread read = new Thread(new Runnable() {
public void run() {
for(int i = 0;i < 10;i++){
t.get("i");
}
}
});
write.start();
read.start();
}
}
实验结果:
通过实验结果,我们可以发现,在写入资源时,我们调用写锁的lock()方法,写入结束调用unlock()方法;在读取资源时,我们调用读锁的lock()方法,写入结束调用unlock()方法,并且在写入操作没有完成之前,是不能进行读取操作的。
3.乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。在Java中,synchronized的思想也是悲观锁。
乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。悲观锁在Java中的使用,就是利用各种锁。乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
4.cas无锁机制
CAS:Compare and Swap,即比较再交换。
jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。
5.CAS算法理解
(1)与锁相比,使用比较交换(下文简称CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。
(2)无锁的好处:
第一,在高并发的情况下,它比有锁的程序拥有更好的性能;
第二,它天生就是死锁免疫的。
就凭借这两个优势,就值得我们冒险尝试使用无锁的并发。
(3)CAS算法的过程是这样:它包含三个参数CAS(V,E,N): V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则将主内存的值刷新到本地内存,再去做比较,一直重试。最后,CAS返回当前V的真实值。
V=需要更新变量,主内存
E=预望值,本地内存
N=新值
如果V=E(主内存值与本地内存值一致),说明:没有被修改过,将V的值设置为N。
如果V!=E(主内存值与本地内存值不一致),已经被修改。
(4)CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
(5)简单地说,CAS需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,那说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。
(6)在硬件层面,大部分的现代处理器都已经支持原子化的CAS指令。在JDK 5.0以后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构,并且,这种操作在虚拟机中可以说是无处不在。