对锁的总结

发布于:2025-09-04 ⋅ 阅读:(12) ⋅ 点赞:(0)

一、概述

       Java提供各种各样的锁实现方式,每种锁的特点都不太一样,在适当的场景下能够展现出非常高的效率。由于Java中的锁,是按照是否包含某一种特性来定义锁,所以按照锁的特性,可以将锁进行分组归类:

二、悲观锁VS乐观锁

      悲观锁和乐观锁,是两种不同的并发控制思想,体现了对线程同步的不同控制方式和强度。在Java和数据库中都有乐观锁和悲观锁的具体实现和使用。

2.1 悲观锁的概念

       悲观锁 (Pessimistic Locking),是一种悲观的并发控制思想。如果不同线程对同一个数据产生并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。

       所以,悲观锁具有强烈的独占性和排他性,因此,在悲观锁在持有数据的过程中,总会把资源或者数据处于锁定状态,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。

       Java 中的 Synchronized 和 ReentrantLock 是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock不管是否持有资源,它都会尝试去加锁。

// ------------------------- 悲观锁的调用方式 -------------------------

// synchronized
public synchronized void testMethod() {
	// 操作同步资源
}

// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
	lock.lock();
	// 操作同步资源
	lock.unlock();
}

2.2 乐观锁的概念

       乐观锁是一种乐观的并发控制思想,它认为自己在使用数据时不会有别的线程修改数据,所以使用时不会添加锁,只是在更新数据的时候,才会去判断之前有没有别的线程更新了这个数据。

       如果这个数据没有被更新,当前线程将自己修改的数据成功写入。

       如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试(自旋))。

       乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法。

// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger();  // 需要保证多个线程使用的是同一个AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1

2.3 区别

悲观锁适合写操作多的场景,先加锁,可以保证写操作时数据正确。

乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。

三、自旋锁VS适应性自旋锁

3.1 为什么会存在自旋锁?

       挂起和恢复一个Java线程,需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

       在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,挂起线程和恢复线程的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程,在不放弃CPU的执行时间前提下“稍等一下”,看看持有锁的线程是否很快就会释放锁。

       而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后,前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。

3.2 自旋锁

       自旋锁不能代替阻塞,本身是有缺点的,因为自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

       自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

3.3 自适应自旋锁

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

四、偏向锁VS轻量级锁VS重量级锁

4.1 synchronized实现原理

       Synchronized能实现线程同步,主要是因为Java对象头”和“Monitor监视器”。

Java对象头

      Synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁的状态,就保存在Java对象头里。

       以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

       Mark Word:默认存储对象的HashCode,GC分代年龄和锁标志位信息等,这些信息都是与对象自身定义无关的数据。所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

       Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor监视器

       Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。

       Monitor是线程私有的数据结构,每一个代表锁的对象,都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

       Synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。

4.2 四种锁的状态

       JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

       所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

锁状态 存储内容 存储内容
无锁 对象的hashCode、对象分代年龄、是否是偏向锁(0) 01
偏向锁 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向重量级锁(互斥锁)的指针 10

4.3 偏向锁

       偏向锁,是指同步代码被同一个线程所访问,那么该线程会自动获取锁(锁对象偏向于这个线程的获取),降低获取锁的代价。

       在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

       当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。

       引入偏向锁是为了在没有出现多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

       偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

       偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

4.4 轻量级锁

       当锁是偏向锁的时候,被其它线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

       在代码进入同步块的时候,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

       拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

       如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

       如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

4.5 重量级锁

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

4.6 总结

       偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。

       而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。

重量级锁是将除了拥有锁的线程以外的线程都阻塞。

五、公平锁VS非公平锁

5.1 公平锁

       公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会“饿死”(不会产生饥饿线程)。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

5.2 非公平锁

       非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有机率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会产生饥饿线程,或者等很久才会获得锁。

5.3 二者区别

5.4 ReetrantLock中公平锁与非公平锁

       ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。

ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

ReentrantLock中公平锁与非公平锁的加锁方法的源码:

       可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。

       再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。

       综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。


网站公告

今日签到

点亮在社区的每一天
去签到