java并发中的各种锁

一、重入锁

广义上的可重入锁指的是可重复可递归调用的锁,就是说,一个重入锁可以被一个任务(该任务已经持有该锁的情况下)多次获得,这样的锁就叫做可重入锁。ReentrantLock和synchronized都是可重入锁。

    public synchronized void get() {
        set();//在已经获取该对象锁的情况下调用set()方法再次获取锁
    }

    public synchronized void set() {
        
    }
二、不可重入锁

即只可被获取一次的锁,不可重入锁的一个例子是 自旋锁

三、自旋锁

1、自旋锁
Java没有自旋锁的API,所以自旋锁更像是一种锁优化技术
原子操作+自循环。 线程不休眠,一直循环尝试对资源访问,直到可用。这时该线程处于 自旋 状态,直到获取到锁才会退出循环。这种会让其他线程处于自旋状态的锁称为 自旋锁

自旋锁是一种非阻塞锁,也就是说,如果某线程需要获取自旋锁,但该锁已经被其他线程占用时,该线程不会被挂起(该线程并没有sleep或者wait,而是在"运行",CPU仍会给它分配时间),而是处于 自旋 状态,并不断的消耗CPU的时间,不停的试图获取自旋锁。

自旋锁通常是采用让当前线程不停地的在循环体内执行实现的,java中的自旋锁通常采用CAS方式实现,当循环的条件被其他线程改变时 才能进入临界区
下面是一个典型实现:

class SpinLock {
    //该AtomicReference保存了一个Thread对象
	AtomicReference<Thread> lockOwner = new AtomicReference<Thread>();
	private int count;
	
	//如果一个线程利用该锁调用lock()方法,该方法首先会识别该线程,然后判断是否为空。
	public void lock() {
		Thread cur = Thread.currentThread();//识别该线程
		
        //如果lockOwner为空(即没有被线程持有),就将lockOwner设置为该线程,并跳出循环,
        //这时,该线程进入临界区
        //如果过一段时间,另一个线程也尝试获取该锁,即调用lock.lock(),
        //此时lockOwner在compareAndSet()这个地方会发现已经有持有者了,便会让这
        //第二个线程进入while循环中自旋
		while (!owner.compareAndSet(null, cur)){ 
		//第二个线程一直在while内部,直到前一个线程释放锁(锁调用unlock()),
		//第二个线程才能进入临界区。
		}
	}
	public void unLock() {
		Thread cur = Thread.currentThread();
			owner.compareAndSet(cur, null);
		}
	}
}

自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(通常是10次)没有成功获得锁,就应当挂起线程。

自旋锁可能引发的问题:
1.过多占据CPU时间: 如果锁的当前持有者长时间不释放该锁,那么等待者将长时间的占据cpu时间片,导致CPU资源的浪费,因此可以设定一个时间,当锁持有者超过这个时间不释放锁时,等待者会放弃CPU时间片阻塞;
2.死锁问题: 试想一下,有一个线程连续两次试图获得自旋锁(比如在递归程序中),第一次这个线程获得了该锁,当第二次试图加锁的时候,检测到锁已被占用(其实是被自己占用),那么这时,线程会一直等待自己释放该锁,而不能继续执行,这样就引起了死锁。因此递归程序使用自旋锁应该遵循以下原则:递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。

适用场景
如果是多核处理器,如果预计线程等待锁的时间很短,短到比线程两次上下文切换时间要少的情况下,使用自旋锁是划算的。原因如下:

先看看CPU的两种工作模式:
(1)Kernel Mode
在内核模式下,代码具有对硬件的所有控制权限。可以执行所有CPU指令,可以访问任意地址的内存。内核模式是为操作系统最底层,最可信的函数服务的。在内核模式下的任何异常都是灾难性的,将会导致整台机器停机。

(2)User Mode
在用户模式下,代码没有对硬件的直接控制权限,也不能直接访问地址的内存。程序是通过调用系统接口(System APIs)来达到访问硬件和内存。在这种保护模式下,即时程序发生崩溃也是可以恢复的。在你的电脑上大部分程序都是在用户模式下运行的。

我们通常所说的线程是轻量级进程。轻量级进程是基于内核线程实现的,所以各种进程操作,如创建/析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换;每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程需要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的轻量级进程是有限的。

自旋锁出现的原因是人们发现大多数时候锁的占用只会持续很短的时间,甚至低于切换到kernal mode所花的时间,所以在切换到kernal mode前让线程等待有限的时间(自适应自旋,也就是自旋锁中的那个循环),如果在此时间内能够获取到锁就避免了很多无谓的时间,若不能则再进入kernal mode竞争锁。

2、自适应自旋锁

自适应 意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

四、乐观锁和悲观锁

1、乐观锁
乐观锁非常乐观,每次取数据时认为不会有线程对数据修改,在更新时会判断其他线程在这之前有没有修改。

乐观锁,可以使用版本号机制和CAS算法实现

版本号机制
具体实现是给数据库表加一个 version 字段,用来记录数据库表被更新的次数,表被修改时,version加一。线程更新数据库时会读到version的值,在提交更新时,当前读到的version的值 >= 数据库中的version值才更新。
CAS算法
java.util.concurrent.atomic包下面的原子变量类就是使用CAS实现的。
CAS算法是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步。

CAS算法涉及到三个操作数:
(1) 需要读写的内存值 V
(2) 进行比较的值 A
(3) 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

2、悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改数据,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized获取的对象锁和ReentrantLock等独占锁就是悲观锁思想的实现。

五、竞争锁和非竞争锁

非竞争锁
非竞争锁分两种情况:
1、如果一个锁自始至终只被一个线程使用, JVM 有能力优化它带来的绝大部分损耗。
2、一个锁被多个线程使用过,但是在任意时刻,只有一个线程尝试获取该锁,这个情况开销稍微大一些。

JVM对非竞争锁做了很多 优化,使它们几乎不会对性能造成影响,常见的优化有以下几种:
(1) 如果一个锁对象只能由当前线程访问,那么其他线程无法获得该锁并发生同步 , 因此 JVM 可以去除对这个锁的请求。
(2) 逸出分析 (escape analysis) 可以识别本地对象的引用是否在堆中被暴露。如果没有,就可以将本地对象的引用变为线程本地的 (thread local) 。
(3) 编译器还可以进行锁的粗化 (lock coarsening) 。把邻近的 synchronized 块用相同的锁合并起来,以减少不必要的锁的获取和释放。

因此,不要过分担心非竞争锁带来的开销,要关注那些真正发生了锁竞争的临界区中性能的优化。

竞争锁:
多个线程同时尝试获取的锁称为竞争锁,这种情况是 JVM 无法优化的,而且通常会发生从用户态到内核态的切换。

在保证程序正确性的前提下,解决同步带来的性能损失的第一步不是去除锁,而是降低锁的竞争。通常,有以下三类方法可以降低锁的竞争:减少持有锁的时间,降低请求锁的频率,或者用其他协调机制取代独占锁。

refered from https://www.ibm.com/developerworks/cn/java/j-lo-lock/index.html

六、无锁 、偏向锁 、轻量级锁 和 重量级锁

该部分内容参考自
https://juejin.im/post/5bee576fe51d45710c6a51e0#heading-3
以及
https://www.zhihu.com/question/39009953

这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态之前还需要介绍一些额外的知识。

首先为什么Synchronized能实现线程同步?
在回答这个问题之前我们需要了解两个重要的概念:“Java对象头”、“Monitor”。

Java对象头
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?
我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

Mark Word: 默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point: 对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

所以目前锁一共有 4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。这几个状态会随着竞争情况逐渐升级。锁状态只能升级不能降级。

通过上面的介绍,我们对synchronized的加锁机制以及相关知识有了一个了解,那么下面我们给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:
在这里插入图片描述
锁的优缺点对比:
在这里插入图片描述

1、无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是 修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

2、偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

3、轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

4、重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

七、公平锁和非公平锁

公平锁 是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁 是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现 后申请锁的线程先获取锁 的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

Java中的ReentrantLock 默认的lock() 方法采用的是非公平锁。
java.util.concurrent.locks.AbstractQueuedSynchronizer类很重要,几乎所有locks包下的工具类锁都包含了该类的static子类,足以可见这个类在java并发锁工具类当中的地位。这个类提供了对操作系统层面线程操作方法的封装调用,可以帮助并发设计者设计出很多优秀的API

更多惊喜:https://www.zhihu.com/question/36964449

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值