Java多线程中 的各种锁

154 篇文章 5 订阅
150 篇文章 6 订阅

学习 java 多线程时,最头疼的知识点之一就是 java 中的锁了,什么互斥锁、排它锁、自旋锁、死锁、活锁等等,细分的话可以罗列出 20 种左右的锁,光是看着这些名字就足以让人望而却步了,更别说一个个去理解它们的含义了。其实我要在这里告诉大家,我们看到的其实只是假象,其实根本没有这么多锁,或者这样说,这里边有很多锁其实就是一个东西,当我们从不同的侧重点去看的时候,它们就会衍生出不同的名字。


一、由 ReentrantLock 和 synchronized 实现的一系列锁

jdk1.5 的 java.util.concurrent 并发包中的 Lock 接口和 1.5 之前的 synchronized 或许是我们最常用的同步方式,这两种同步方式特别是 Lock 的 ReentrantLock 实现,经常拿来进行比较,其实他们有很多相似之处,其实它们在实现同步的思想上大致相同,只不过在一些细节的策略上(诸如抛出异常是否自动释放锁)有所不同。前边说过了,本文着重讲锁的实现思想和不同锁的概念与分类,不对实现原理的细节深究,因此我在下面介绍第一类锁的时候经常讲他们放在一起来说。我们先来说一下 Lock 接口的实现之一 ReentrantLock。当我们想要创建 ReentrantLock 实例的时候,jdk 为我们提供两种重载的构造函数,如图:
在这里插入图片描述
fair 是什么意思?公平的意思,没错,这就是我们要说的第一种锁。

1. 从其它等待中的线程是否按顺序获取锁的角度划分 – 公平锁与非公平锁

我先做个形象比喻,比如现在有一个餐厅,一次最多只允许一个持有钥匙的人进入用餐,那么其他没拿到钥匙的人就要在门口等着,等里面那个人吃完了,他出来他把钥匙扔地上,后边拿到钥匙的人才能进入餐厅用餐。

  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。所用公平锁就好像在餐厅的门口安装了一个排队的护栏,谁先来的谁就站的靠前,无法进行插队,当餐厅中的人用餐结束后会把钥匙交给排在最前边的那个人,以此类推。公平锁的好处是,可以保证每个排队的人都有饭吃,先到先吃后到后吃。但是弊端是,要额外安装排队装置。
  • 非公平锁:理解了公平锁,非公平锁就很好理解了,它无非就是不用排队,当餐厅里的人出来后将钥匙往地上一扔,谁抢到算谁的。但是这样就造成了一个问题,那些身强体壮的人可能总是会先抢到钥匙,而那些身体瘦小的人可能一直抢不到,这就有可能将一直抢不到钥匙,最后导致需要很长时间才能拿到钥匙甚至一直拿不到直至饿死。

公平锁与非公平所的总结:

(1) 公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些;非公平锁的好处是整体效率相对高一些,但是有些线程可能会饿死或者说很早就在等待锁,但要等很久才会获得锁。其中的原因是公平锁是严格按照请求所的顺序来排队获得锁的,而非公平锁时可以抢占的,即如果在某个时刻有线程需要获取锁,而这个时候刚好锁可用,那么这个线程会直接抢占,而这时阻塞在等待队列的线程则不会被唤醒。

(2) 在 java 中,公平锁可以通过 new ReentrantLock (true) 来实现;非公平锁可以通过 new ReentrantLock (false) 或者默认构造函数 new ReentrantLock () 实现。

(3)synchronized 是非公平锁,并且它无法实现公平锁。


2. 从能否有多个线程持有同一把锁的角度划分 – 互斥锁

互斥锁的概念非常简单,也就是我们常说的同步,即一次最多只能有一个线程持有的锁,当一个线程持有该锁的时候其它线程无法进入上锁的区域。在 Java 中 synchronized 就是互斥锁,从宏观概念来讲,互斥锁就是通过悲观锁的理念引出来的,而非互斥锁则是通过乐观锁的概念引申的。


3. 从一个线程能否递归获取自己的锁的角度划分 – 重入锁(递归锁)

我们知道,一条线程若想进入一个被上锁的区域,首先要判断这个区域的锁是否已经被某条线程所持有。如果锁正在被持有那么线程将等待锁的释放,但是这就引发了一个问题,我们来看这样一段简单的代码:

public class ReentrantDemo {
	private Lock mLock;
 
	public ReentrantDemo(Lock mLock) {
		this.mLock = mLock;
	}
 
	public void outer() {
		mLock.lock();
		inner();
		mLock.unlock();
	}
 
	public void inner() {
		mLock.lock();
		// do something
		mLock.unlock();
	}
}

当线程 A 调用 outer () 方法的时候,会进入使用传进来 mlock 实例来进行 mlock.lock () 加锁,此时 outer () 方法中的这片区域的锁 mlock 就被线程 A 持有了,当线程 B 想要调用 outer () 方法时会先判断,发现这个 mlock 这把锁被其它线程持有了,因此进入等待状态。我们现在不考虑线程 B,单说线程 A,线程 A 进入 outer () 方法后,它还要调用 inner () 方法,并且 inner () 方法中使用的也是 mlock () 这把锁,于是接下来有趣的事情就来了。按正常步骤来说,线程 A 先判断 mlock 这把锁是否已经被持有了,判断后发现这把锁确实被持有了,但是可笑的是,是 A 自己持有的。那你说 A 能否在加了 mlock 锁的 outer () 方法中调用加了 mlock 锁的 inner 方法呢?答案是如果我们使用的是可重入锁,那么递归调用自己持有的那把锁的时候,是允许进入的。

  • 可重入锁:可以再次进入方法 A,就是说在释放锁前此线程可以再次进入方法 A(方法 A 递归)。
  • 不可重入锁(自旋锁):不可以再次进入方法 A,也就是说获得锁进入方法 A 是此线程在释放锁前唯一的一次进入方法 A。

下面这段代码演示了不可重入锁

public class Lock{  
    private boolean isLocked = false;  
    public synchronized void lock()  
        throws InterruptedException{  
        while(isLocked){  
            wait();  
        }  
        isLocked = true;  
    }  
 
    public synchronized void unlock(){  
        isLocked = false;  
        notify();  
    }  
}  

可以看到,当 isLocked 被设置为 true 后,在线程调用 unlock () 解锁之前不管线程是否已经获得锁,都只能 wait ()。


4. 从编译器优化的角度划分 – 锁消除和锁粗化

锁消除和锁粗化,是编译器在编译代码阶段,对一些没有必要的、不会引起安全问题的同步代码取消同步(锁消除)或者对那些多次执行同步的代码且它们可以合并到一次同步的代码(锁粗化)进行的优化手段,从而提高程序的执行效率。

锁消除
对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据是来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而能被其他线程访问到,那就可以把他们当做栈上数据对待,认为他们是线程私有的,同步加锁自然就无需进行。

来看这样一个方法:

    public String concatString(String s1, String s2, String s3)
    {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
    }

源码中 StringBuffer 的 append 方法定义如下:

    public synchronized StringBuffer append(StringBuffer sb) {
        super.append(sb);
        return this;
    }

可见 append 的方法使用 synchronized 进行同步,我们知道对象的实例总是存在于堆中被多线程共享,即使在局部方法中创建的实例依然存在于堆中,但是对该实例的引用是线程私有的,对其他线程不可见。即上边代码中虽然 StringBuffer 的实例是共享数据,但是对该实例的引用确是每条线程内部私有的。不同的线程引用的是堆中存在的不同的 StringBuffer 实例,它们互不影响互不可见。也就是说在 concatString () 方法中涉及了同步操作。但是可以观察到 sb 对象它的作用域被限制在方法的内部,也就是 sb 对象不会 “逃逸” 出去,其他线程无法访问。因此,虽然这里有锁,但是可以被安全的消除,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化
原则上,我们在编写代码的时候,总是要将同步块的作用范围限制的尽量小 —— 只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁禁止,那等待的线程也能尽快拿到锁。大部分情况下,这些都是正确的。但是,如果一系列的联系操作都是同一个对象反复加上和解锁,甚至加锁操作是出现在循环体中的,那么即使没有线程竞争,频繁地进行互斥同步操作也导致不必要的性能损耗。

举个案例,类似上面锁消除的 concatString () 方法。如果 StringBuffer sb = new StringBuffer (); 定义在方法体之外,那么就会有线程竞争,但是每个 append () 操作都对同一个对象反复加锁解锁,那么虚拟机探测到有这样的情况的话,会把加锁同步的范围扩展到整个操作序列的外部,即扩展到第一个 append () 操作之前和最后一个 append () 操作之后,这样的一个锁范围扩展的操作就称之为锁粗化。


5. 在不同的位置使用 synchronized-- 类锁和对象锁

这是最常见的锁了,synchronized 作为锁来使用的时候,无非就只能出现在两个地方(其实还能修饰变量,但作用是保证可见性,这里讨论锁,故不阐述):代码块、方法(一般方法、静态方法)。由于可以使用不同的类型来作为锁,因此分成了类锁和对象锁。

  • 类锁:使用字节码文件(即.class)作为锁。如静态同步函数(使用本类的.class),同步代码块中使用.class。
  • 对象锁:使用对象作为锁。如同步函数(使用本类实例,即 this),同步代码块中是用引用的对象。

下面代码涵盖了所有 synchronized 的使用方式:

public class Demo {
	public Object obj = new Object();
 	 // 静态同步函数,使用本类字节码做类锁(即Demo.class)
	public static synchronized void method1() {
	}
 
	public void method2() {
		// 同步代码块,使用字节码做类锁
		synchronized (Demo.class) { 
		}
	}
 
 	// 同步函数,使用本类对象实例即this做对象锁
	public synchronized void method3() { 
	}
 
	// 同步代码块,使用本类对象实例即this做对象锁
	public void method4() {
		synchronized (this) { 
		}
	}
 
	public void method5() {
		//同步代码块,使用共享数据obj实例做对象锁。
		synchronized (obj) { 
		}
	}
}

二、从锁的设计理念来分类 – 悲观锁、乐观锁

如果将锁在宏观上进行大的分类,那么所只有两类,即悲观锁和乐观锁。

  • 悲观锁
    悲观锁是就是悲观思想,即认为读少写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java 中的悲观锁就是 Synchronized,AQS 框架下的锁则是先尝试 cas 乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock。

  • 乐观锁
    乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读 - 比较 - 写的操作。

乐观锁的实现思想 --CAS(Compare and Swap)无锁

CAS 并不是一种实际的锁,它仅仅是实现乐观锁的一种思想,java 中的乐观锁(如自旋锁)基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

乐观锁常见的两种实现方式

乐观锁一般会使用版本号机制CAS 算法实现

  1. 版本号机制
    一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
  2. CAS 算法
    即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。 CAS 算法涉及到三个操作数
    • 需要读写的内存值 V
    • 进行比较的值 A
    • 拟写入的新值 B
      当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

在这里插入图片描述


三、数据库中常用到的锁 – 共享锁、排它锁

共享锁和排它锁多用于数据库中的事物操作,主要针对读和写的操作。而在 Java 中,对这组概念通过 ReentrantReadWriteLock 进行了实现,它的理念和数据库中共享锁与排它锁的理念几乎一致,**即一条线程进行读的时候,允许其他线程进入上锁的区域中进行读操作;当一条线程进行写操作的时候,不允许其他线程进入进行任何操作。**即读 + 读可以存在,读 + 写、写 + 写均不允许存在

  • 共享锁:也称读锁或 S 锁。如果事务 T 对数据 A 加上共享锁后,则其他事务只能对 A 再加共享锁,不能加排它锁。获准共享锁的事务只能读数据,不能修改数据。
  • 排它锁:也称独占锁、写锁或 X 锁。如果事务 T 对数据 A 加上排它锁后,则其他事务不能再对 A 加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。

四、由于并发问题产生的锁 – 死锁、活锁

死锁

1.什么是死锁

所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。下面我通过一些实例来说明死锁现象。

先看生活中的一个实例,2 个人一起吃饭但是只有一双筷子,2 人轮流吃(同时拥有 2 只筷子才能吃)。某一个时候,一个拿了左筷子,一人拿了右筷子,2 个人都同时占用一个资源,等待另一个资源,这个时候甲在等待乙吃完并释放它占有的筷子,同理,乙也在等待甲吃完并释放它占有的筷子,这样就陷入了一个死循环,谁也无法继续吃饭。

在计算机系统中也存在类似的情况。例如,某计算机系统中只有一台打印机和一台输入 设备,进程 P1 正占用输入设备,同时又提出使用打印机的请求,但此时打印机正被进程 P2 所占用,而 P2 在未释放打印机之前,又提出请求使用正被 P1 占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程陷入死锁状态。

2.死锁形成的必要条件

产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合 {Pl, P2, …, pn},其中 Pi 等 待的资源被 P (i+1) 占有(i=0, 1, …, n-1),Pn 等待的资源被 P0 占有
活锁

活锁和死锁在表现上是一样的两个线程都没有任何进展,但是区别在于:死锁,两个线程都处于阻塞状态,说白了就是它不会再做任何动作,我们通过查看线程状态是可以分辨出来的。而活锁呢,并不会阻塞,而是一直尝试去获取需要的锁,不断的 try,这种情况下线程并没有阻塞所以是活的状态,我们查看线程的状态也会发现线程是正常的,但重要的是整个程序却不能继续执行了,一直在做无用功。举个生动的例子的话,两个人都没有停下来等对方让路,而是都有很有礼貌的给对方让路,但是两个人都在不断朝路的同一个方向移动,这样只是在做无用功,还是不能让对方通过。

原文:https://blog.csdn.net/renwei289443/article/details/79540809

  • 4
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Java多线程的使用是为了控制对共享资源的访问,以避免多个线程同时对同一资源进行修改而导致数据不一致或竞态条件的问题。Java提供了两种的机制:synchronized关键字和Lock接口。 1. synchronized关键字: - synchronized关键字可以用来修饰方法或代码块,使其成为同步方法或同步块。 - 当一个线程访问同步方法或同步块时,会自动获取该方法或代码块所在对象的,并在执行后释放。 - 其他线程在获取之前会被阻塞,直到被释放。 - 示例代码: ```java public synchronized void synchronizedMethod() { // 同步方法 } public void synchronizedBlock() { synchronized (this) { // 同步块 } } ``` 2. Lock接口: - Lock接口是Java提供的显示机制,提供了更灵活的定方式。 - Lock接口的常用实现类是ReentrantLock,它具有与synchronized相似的语义。 - 示例代码: ```java Lock lock = new ReentrantLock(); public void lockMethod() { lock.lock(); try { // 加的代码 } finally { lock.unlock(); // 必须在finally块释放,以防止异常导致无法释放 } } ``` 在使用时,需要注意以下几点: - 的粒度应尽量小,只定必要的代码块,以减少线程间的竞争。 - 避免死,即多个线程相互等待对方释放的情况。 - 保证的正确使用,避免忘记释放或错误地释放,可以使用try-finally语句块来确保的释放。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值