序言
由于最近项目上遇到了高并发问题,而自己对高并发,多线程这里的知识点相对薄弱,尤其是基础,所以想系统的学习一下,以后可能会出一系列的JUC文章及总结 ,同时也为企业级的高并发项目做好准备。
本文是JUC文章的第四篇,如想看以往关于JUC文章,请点击JUC系列总结
此系列文章的总结思路大致分为三部分:
- 理论(概念);
- 实践(代码证明);
- 总结(心得及适用场景)。
在这里提前说也是为了防止大家看着看着就迷路了。
备注:此文在拥有相关线程基础阅读为最佳,比如cas,synchronized,reentrantLock等。
java锁大纲
- 从锁的公平性来区分,可以分为
公平锁
和非公平锁
; - 从锁是否可重复获取可分为
可重入锁
和不可重入锁
; - 从资源已被锁定,线程是否阻塞可以分为
自旋锁
; - 从线程是否对资源加锁可以分为
悲观锁
和乐观锁
; - 从那个多个线程能否获取同一把锁分为
共享锁
和排他锁
。 - 多Jvm环境下多线程操作多个资源类分为
分布式锁
。
公平锁与非公平锁
公平锁
什么是公平锁呢?
公平锁就是多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
其实白话来讲,就是多个线程排成一队,先来后到,先到先得。
优缺点
优点:
- 很公平,所有的线程都能得到资源,不会造成线程饥饿。
缺点:
- 并发度低,cpu唤醒阻塞线程的开销大。
非公平锁
什么是非公平锁呢?
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
白话来讲,就是多个线程抢夺,插队现象。
优缺点
优点:
- 并发度高,减少CPU唤醒线程的开销。
缺点:
- 导致优先级反转或饥饿现象
在这里说明一个细节问题:
synchronized
和juc.ReentrantLock
默认都是非公平锁。ReentrantLock
在构造的时候传入true
则是公平锁。
代码演示
class MyResource{
//非公平锁
// private final ReentrantLock reentrantLock = new ReentrantLock();
//公平锁
private final ReentrantLock reentrantLock = new ReentrantLock(true);
public void ifFair(){
System.out.println(Thread.currentThread().getName() +"线程 \t已经进入方法");
try{
reentrantLock.lock(); System.err.println(Thread.currentThread().getName()+"线程====== \t线程获得了锁");
}catch(Exception e){
e.getStackTrace();
}finally {
//释放锁
reentrantLock.unlock();
}
}
}
public class FairLockTest {
public static void main(String[] args) {
MyResource myResource = new MyResource();
for (int i = 0; i < 8; i++) {
new Thread(() ->{
myResource.ifFair();
},String.valueOf(i)).start();
}
}
}
公平锁输出如下:
0线程 已经进入方法
6线程 已经进入方法
4线程 已经进入方法
3线程 已经进入方法
1线程 已经进入方法
2线程 已经进入方法
7线程 已经进入方法
5线程 已经进入方法
0线程====== 线程获得了锁
6线程====== 线程获得了锁
4线程====== 线程获得了锁
3线程====== 线程获得了锁
1线程====== 线程获得了锁
2线程====== 线程获得了锁
7线程====== 线程获得了锁
5线程====== 线程获得了锁
可以看出是按申请锁的顺序来进行获得锁的。
非公平锁输出如下:
0线程 已经进入方法
5线程 已经进入方法
4线程 已经进入方法
3线程 已经进入方法
1线程 已经进入方法
2线程 已经进入方法
7线程 已经进入方法
6线程 已经进入方法
0线程====== 线程获得了锁
4线程====== 线程获得了锁
1线程====== 线程获得了锁
2线程====== 线程获得了锁
7线程====== 线程获得了锁
6线程====== 线程获得了锁
5线程====== 线程获得了锁
3线程====== 线程获得了锁
获取锁的顺序已被打乱。
可重入锁与不可重入锁
可重入锁
什么是可重入锁呢?
可重入锁,又名递归锁,指的是同一个线程在外层方法获得锁时,进入内层方法会自动获取锁。
白话来讲,可以这么形容,就像你有了家门的锁,厕所、书房、厨房就为你敞开了一样(即便他们每个都有锁)。可重入锁可以避免死锁的问题。
我们常用的ReenTrantLock
和synchronized
都是可重入锁的体现。
不可重入锁
与可重入锁相反,即便它有大门钥匙,厕所、书房、厨房你照样进不去。
Lock
锁为不可重入锁的体现。
锁的配对
锁之间要配对,加了几把锁,最后就得解开几把锁,下面的代码编译和运行都没有任何问题。但锁的数量不匹配会导致死循环。
如下就会导致死循环问题:
lock.lock();
lock.lock();
try{
someAction();
}finally{
lock.unlock();
}
代码演示
可重入锁演示
class MyData{
public synchronized void get(){
System.out.println(Thread.currentThread().getName() +"线程 \t"+ "获取了值");
set();
}
private synchronized void set() {
System.out.println(Thread.currentThread().getName()+"线程\t"+"插入了值");
}
final ReentrantLock reenTrantLock = new ReentrantLock();
public void push(){
try{
reenTrantLock.lock();
System.out.println(Thread.currentThread().getName() +"线程\t"+"生产了产品");
poll();
}catch(Exception e){
e.getStackTrace();
}finally {
reenTrantLock.unlock();
}
}
private void poll() {
try{
reenTrantLock.lock();
System.out.println(Thread.currentThread().getName() +"线程\t"+"消费了产品");
}catch(Exception e){
e.getStackTrace();
}finally {
reenTrantLock.unlock();
}
}
}
public class ReentrancyTest {
public static void main(String[] args) {
MyData myData = new MyData();
//证明synchronized的可重入性
new Thread(() ->{
myData.get();
}).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.err.println("====以下为证明ReentrantLock的可重入性====");
//证明ReentrantLock的可重入性
new Thread(() ->{
myData.push();
}).start();
}
}
结果如下:
Thread-0线程 获取了值
Thread-0线程 插入了值
===============以下为证明ReentrantLock的可重入性===================
Thread-1线程 生产了产品
Thread-1线程 消费了产品
不可重入锁演示
//构造一个不可重入锁
class NoReenTrantLock{
private boolean isLock = false;
public synchronized void lock() throws InterruptedException {
while (isLock){
wait();
}
isLock = true;
}
public synchronized void unLock(){
isLock = false;
notify();
}
}
//构造资源
class NoReenTrantResource{
private NoReenTrantLock noReenTrantLock = new NoReenTrantLock();
public void send() throws InterruptedException {
noReenTrantLock.lock();
System.out.println(Thread.currentThread().getName()+"线程\t"+"发送成功");
receive();
noReenTrantLock.unLock();
}
public void receive() throws InterruptedException {
noReenTrantLock.lock();
System.out.println(Thread.currentThread().getName()+"线程\t"+"接收成功");
noReenTrantLock.unLock();
}
}
public class NoReenTrantcyTest {
public static void main(String[] args) throws InterruptedException {
//测试
new NoReenTrantResource().send();
//结果
//main线程 发送成功(并且程序一直出于阻塞状态)
//其实另一种理解可以为 可重入锁有效的避免的死锁问题。
}
}
结果如下:
自旋锁
什么是自旋锁呢?
自旋锁,就是一个线程尝试去获取某一把锁的时候不会立即阻塞,而是采用循环的方式去尝试获取。自己在那儿一直循环获取,就像“自旋”一样。
优缺点
优点:
- 减少线程切换的上下文开销,避免了用户进程和内核切换的消耗;
缺点:
-
如果长时间上锁的话,自旋锁会非常耗费性能(消耗CPU,它阻止了其他线程的运行和调度)。
可以通过设置自旋时间。自旋时间可由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
CAS底层的getAndAddInt
就是自旋锁思想。
//跟CAS类似,一直循环比较。
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
使用场景
-
锁的竞争不激烈,且占用锁时间非常短的代码块,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
-
如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了;
因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cpu 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁。
代码演示
//构造自旋锁
class spinLock{
private AtomicBoolean islock = new AtomicBoolean(false);
public void lock(){
while(!tryLock()){
System.err.println(Thread.currentThread().getName() +"线程 \t"+"尝试自旋获取锁");
}
System.out.println(Thread.currentThread().getName() +"线程 \t"+"获得了锁");
}
private boolean tryLock() {
return islock.compareAndSet(false,true);
}
public void unLock(){
while(!islock.compareAndSet(true,false)){
System.out.println(Thread.currentThread().getName() +"线程 \t"+"正在自旋解锁");
}
System.out.println(Thread.currentThread().getName() +"线程 \t"+"已解锁");
}
public void contrLock(AtomicBoolean islock){
this.islock = islock;
}
}
//构建资源类
class spinLockResource{
private spinLock spinLock = new spinLock();
//演示自旋锁的获取过程
public void send(){
spinLock.lock();
System.out.println("发送成功");
spinLock.unLock();
}
//控制自旋状态
public void contrLock(AtomicBoolean islock){
spinLock.contrLock(islock);
}
}
//测试
public class SpinLockTest {
public static void main(String[] args) {
spinLockResource resource = new spinLockResource();
//资源操作线程
new Thread(() ->{
try{TimeUnit.MILLISECONDS.sleep(10);}catch(Exception e){e.getStackTrace();};
//演示自旋操作
resource.send();
},"oper").start();
//控制线程
new Thread(() ->{
resource.contrLock(new AtomicBoolean(true));
try{TimeUnit.MILLISECONDS.sleep(30);}catch(Exception e){e.getStackTrace();};
resource.contrLock(new AtomicBoolean(false));
},"control").start();
}
}
结果显示:
oper线程 尝试自旋获取锁
...
oper线程 尝试自旋获取锁
oper线程 尝试自旋获取锁
oper线程 尝试自旋获取锁
oper线程 尝试自旋获取锁
oper线程 尝试自旋获取锁
oper线程 获得了锁
发送成功
oper线程 已解锁
悲观锁与乐观锁
悲观锁
什么是悲观锁呢?
悲观锁,其实他是一种悲观思想,它总认为别人在操作数据/资源的时候,会对数据/资源发生更改,从而在其持有数据/资源的时候,将其锁住,到这另一个线程过来时发生阻塞,知道悲观锁将数据/资源释放为止。
其中,在传统的数据库中就运用了这种思想,比如行锁,表锁,写锁等…
而在Java层面上讲,Synchronized
和 ReentrantLock
等独占锁(排他锁都是其思想的实现
因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁,生怕自己心爱的宝贝被别人拿走。
乐观锁
什么时乐观锁呢?
乐观锁,顾名思义,是一种乐观的思想,它总认为数据/资源不会被别人修改,所以在读取上不会上锁,但是在写入操作的时候,他会进行判断当前数据是否被修改过。具体实现方案有两种:
-
版本号机制;
一般会在在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会+1。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
核心代码:
update table set x=x+1, version=version+1 where id=#{id} and version=#{version};
-
CAS实现。
即compare and swap 或者 compare and set,涉及到三个操作数,数据所在的内存值V,预期值A,新值B。当需要更新时,判断当前V与A是否相等,若相等,则用B更新,若失败则重试,一般情况下是一个自旋操作,即不断的重试。
乐观锁的缺点
- 以上两种机制均存在ABA问题,在CAS详解及ABA问题的解决我们也层复述过,在这里我们就不做过多解释;
- 在并发度高,写入频繁的场景下,导致自旋循坏开销大。
使用场景
读取频繁使用乐观锁,写入频繁使用悲观锁。
共享锁与排他锁
共享锁
共享锁是什么呢?
共享锁,又称读锁,指的是允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有。
如果某个线程对资源加上共享锁后,则其他线程只能对资源再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
排他锁
排他锁是什么呢?
排他锁,又称独占锁,指的是一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。
synchronized
、ReentrantLock
和Lock
都是独占锁的体现。
读写锁
另外,我们在这里说一个比较特殊的机制,ReentrantReadWriteLock
。
它的内部含有两把锁,ReadLock
和 WriteLock
,也就是一个读锁一个写锁,合在一起叫做读写锁。
读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
比如缓存,就需要读写锁来控制。缓存就是一个键值对,以下Demo模拟了缓存的读写操作,读的get
方法使用了ReentrantReadWriteLock.ReadLock()
,写的put
方法使用了ReentrantReadWriteLock.WriteLock()
。这样避免了写被打断,实现了多个线程同时读。
代码演示
class MyCache{
private volatile HashMap<String,String> map = new HashMap<>();
private ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//写
public void addEle(String key,String value){
reentrantReadWriteLock.writeLock().lock();
try{
System.out.println(Thread.currentThread().getName() + "\t" + "正在写入: " + key);
//为了增强显示效果
try{ TimeUnit.SECONDS.sleep(1);}catch(Exception e){e.getStackTrace();};
map.put(key,value);
System.out.println(Thread.currentThread().getName() + "\t" + "写入完成······");
}catch(Exception e){
e.getStackTrace();
}finally {
reentrantReadWriteLock.writeLock().unlock();
}
}
//读
public void selectEle(String key){
reentrantReadWriteLock.readLock().lock();
try{
System.out.println(Thread.currentThread().getName() + "\t" + "正在读取: " + key);
String result = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t" + "读取完成========== " + result);
}catch(Exception e){
e.getStackTrace();
reentrantReadWriteLock.readLock().unlock();
}
}
}
//测试类
public class ReadWriteLockTest {
public static void main(String[] args) {
MyCache myCache = new MyCache();
//写
for (int i = 0; i < 5; i++) {
final String temp = i+"";
new Thread(() ->{
myCache.addEle(temp,temp);
},"Write"+String.valueOf(i)).start();
}
//读
for (int i = 0; i < 5; i++) {
final String temp = i+"";
new Thread(() ->{
myCache.selectEle(temp);
},"Read"+String.valueOf(i)).start();
}
}
}
在未引入ReenTrantReadWriteLock时的输出效果:
Write0 正在写入: 0
Write2 正在写入: 2
Write2 写入完成``````````````````
Write1 正在写入: 1
Write3 正在写入: 3
Write3 写入完成``````````````````
Write0 写入完成``````````````````
Write4 正在写入: 4
Write4 写入完成``````````````````
Write1 写入完成``````````````````
Read1 正在读取: 1
Read2 正在读取: 2
Read1 读取完成========== 1
Read4 正在读取: 4
Read4 读取完成========== 4
Read2 读取完成========== 2
Read0 正在读取: 0
Read0 读取完成========== 0
Read3 正在读取: 3
Read3 读取完成========== 3
在引入ReenTrantReadWriteLock时的输出效果:
Write0 正在写入: 0
Write0 写入完成``````````````````0
Write1 正在写入: 1
Write1 写入完成``````````````````1
Write2 正在写入: 2
Write2 写入完成``````````````````2
Write3 正在写入: 3
Write3 写入完成``````````````````3
Write4 正在写入: 4
Write4 写入完成``````````````````4
Read0 正在读取: 0
Read0 读取完成========== 0
Read1 正在读取: 1
Read1 读取完成========== 1
Read2 正在读取: 2
Read3 正在读取: 3
Read4 正在读取: 4
Read4 读取完成========== 4
Read2 读取完成========== 2
Read3 读取完成========== 3
分布式锁
zookeeper分布式锁
未完待续…
Redis分布式锁
未完待续…
总结
以上为Java常用的锁,其实在我们知道的基础上,一定要知道是什么,怎么用,为什么用以及使用场景,可能以上文章仅仅只是我的看法,如您有新的看法,请留言,多多沟通…