我们在前面章节也提到过多线程的锁机制,但没有深入的去研究锁的种类以及其用法。在这里做一个深度说明。多线程锁是为了解决有可能产生得线程安全问题,从而保证多线程程序的健壮性和可靠性。本节我们将讨论Java多线程中的各种锁以及其用法。
悲观锁和乐观锁
悲观锁和乐观锁无具体实现,只是概念上的锁。下面会讲到这两种概念锁的具体实现细节何其应用场景。
悲观锁(Pessimistic Lock)
顾名思义,就是很悲观,每次去拿数据的时候都认为会被其他线程修改,所以每次在拿数据的时候都会上锁,这样其他线程想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
举例:在我们对数据库中的一条记录进行操作时,我们可以对其加上悲观锁,是其他更改操作阻塞无法立即修改。
格式 SELECT…FOR UPDATE
例:select * from account where name="Erica" for update
这条 sql 语句锁定了 account 表中所有符合检索条件( name=“Erica” )的记录。 本次事务提交之前(事务提交时会释放事务过程中的锁),外界其他人无法修改这些记录。
当我们不希望被阻塞,遇到加锁的记录就立即返回,不等待释放锁再去修改,可以使用下面的语法格式进行;
格式 SELECT…FOR UPDATE NOWAIT
该关键字的含义是“不用等待,立即返回”,如果当前请求的资源被其他会话锁定时,会发生阻塞,nowait可以避免这一阻塞。
乐观锁(Optimistic Lock)
顾名思义,就是很乐观,每次去拿数据的时候都认为不会被其他线程修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。
乐观锁,大多是基于数据版本 ( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。
读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
悲观锁和乐观锁总结
两种锁各有优缺点,都有其适用的场景,像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。
重入锁
重入锁定义
重进入是指任意线程在获取到锁之后,再次获取该锁而不会被该锁所阻塞。关联一个线程持有者+计数器,重入意味着锁操作的颗粒度为“线程”。在JAVA环境下 ReentrantLock 和synchronized 都是可重入锁。
重入锁原理
线程再次获取锁,锁需要识别获取锁的现场是否为当前占据锁的线程,如果是,则再次成功获取,重入锁的计数器加一;
每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。
public class ThreadDemo extends Thread {
ReentrantLock lock = new ReentrantLock();
public void get() {
lock.lock();
try {
System.out.println(Thread.currentThread().getId());
set();
} finally {
lock.unlock();
}
}
public void set() {
lock.lock();
try {
System.out.println(Thread.currentThread().getId());
} finally {
lock.unlock();
}
}
@Override
public void run() {
get();
}
public static void main(String[] args) {
ThreadDemo ss = new ThreadDemo();
new Thread(ss).start();
new Thread(ss).start();
new Thread(ss).start();
}
}
以上demo使用了ReentrantLock可重入锁,线程在执行get()方法时,已经取得了锁,未释放锁之前,可以在set()方法中再次获取锁,所以当前线程获取了两次锁,必须执行两次释放锁的操作才能真正的释放锁。同理,在使用synchronized关键字是时,也是如此。
读写锁
读写锁介绍
假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁。在没有写操作的时候,两个线程同时读一个资源没有任何问题,所以应该允许多个线程能在同时读取共享资源。但是如果有一个线程想去写这些共享资源,就不应该再有其它线程对该资源进行读或写(也就是说:读-读能共存,读-写不能共存,写-写不能共存)。
Java并发包中ReadWriteLock是一个接口,主要有两个方法,如下:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
ReentrantReadWriteLock分析
ReentrantReadWriteLock有如下特性:
- 非公平模式(默认) 。当以非公平初始化时,读锁和写锁的获取的顺序是不确定的。非公平锁主张竞争获取,可能会延缓一个或多个读或写线程,但是会比公平锁有更高的吞吐量;
- 公平模式 。当以公平模式初始化时,线程将会以队列的顺序获取锁。当前线程释放锁后,等待时间最长的写锁线程就会被分配写锁;或者有一组读线程组等待时间比写线程长,那么这组读线程组将会被分配读锁。当有写线程持有写锁或者有等待的写线程时,一个尝试获取公平的读锁(非重入)的线程就会阻塞。这个线程直到等待时间最长的写锁获得锁后并释放掉锁后才能获取到读锁;
- 可重入。允许读锁可写锁可重入。写锁可以获得读锁,读锁不能获得写锁;
- 锁降级。允许写锁降低为读锁;
- 中断锁的获取。在读锁和写锁的获取过程中支持中断 ;
- 支持Condition 。写锁提供Condition实现;
- 监控。提供确定锁是否被持有等辅助方法。
我们可以查看ReentrantReadWriteLock类的构造器:
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
默认使用的是非公平模式,如果需要使用公平模式创建读写锁,可以通过构造器传参的方式获取。
ReentrantReadWriteLock的具体使用,我们再来看一下下面这个比较常见的案例:
public class ThreadDemo6 {
static Map<String, Object> map = new HashMap<String, Object>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
// 获取一个key对应的value
public static final Object get(String key) {
r.lock();
try {
System.out.println("正在做读的操作,key:" + key + " 开始");
Thread.sleep(10);
Object object = map.get(key);
System.out.println("正在做读的操作,key:" + key + " 结束");
System.out.println();
return object;
} catch (InterruptedException e) {
} finally {
r.unlock();
}
return key;
}
// 设置key对应的value,并返回旧有的value
public static final Object put(String key, Object value) {
w.lock();
try {
System.out.println("正在做写的操作,key:" + key + ",value:" + value + "开始.");
Thread.sleep(10);
Object object = map.put(key, value);
System.out.println("正在做写的操作,key:" + key + ",value:" + value + "结束.");
System.out.println();
return object;
} catch (InterruptedException e) {
} finally {
w.unlock();
}
return value;
}
// 清空所有的内容
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
final int key = i;
new Thread(new Runnable() {
@Override
public void run() {
ThreadDemo6.put(key + "", key + "");
}
}).start();
}
for (int i = 0; i < 3; i++) {
final int key = i;
new Thread(new Runnable() {
@Override
public void run() {
ThreadDemo6.get(key + "");
}
}).start();
}
}
}
运行结果:
正在做写的操作,key:0,value:0开始.
正在做写的操作,key:0,value:0结束.
正在做写的操作,key:1,value:1开始.
正在做写的操作,key:1,value:1结束.
正在做写的操作,key:2,value:2开始.
正在做写的操作,key:2,value:2结束.
正在做读的操作,key:1 开始
正在做读的操作,key:0 开始
正在做读的操作,key:2 开始
正在做读的操作,key:0 结束
正在做读的操作,key:2 结束
正在做读的操作,key:1 结束
由运行结果可以看出,进行写操作时。只能一个线程执行,但在读操作是,可以有多个线程并行执行。
CAS无锁机制
(1)与锁相比,使用比较交换(下文简称CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。
(2)无锁的好处:
第一,在高并发的情况下,它比有锁的程序拥有更好的性能;
第二,它天生就是死锁免疫的。
就凭借这两个优势,就值得我们冒险尝试使用无锁的并发。
(3)CAS算法的过程是这样:它包含三个参数CAS(V,E,N): V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。
(4)CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
(5)简单地说,CAS需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,那说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。
(6)在硬件层面,大部分的现代处理器都已经支持原子化的CAS指令。在JDK 5.0以后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构,并且,这种操作在虚拟机中可以说是无处不在。