前言:为什么引入锁?锁用来干什么?
本篇针对Java中常见的各种锁进行了简单总结,方便更加容易的理解锁机制。
在引入锁这个概念之前,我们要明确一点,即为什么要引入锁,锁用来干什么?
首先,锁的引入
是为了解决多线程安全问题
,具体的安全问题又可细化为如下3个方面:
(1)原子性
(2)可见性
(3)有序性
举一个实际场景下的例子:
假设主内存中有一个int类型的i变量,初始值为0,同时开启2个线程Thread 1和Thread 2,两个线程同时执行i++操作,那么最终的结果是多少?
最终的结果不确定,而是一个范围i≤2
,为什么会这样?
原因就在于i++操作并非
一个原子操作
,i++在Java
里我们认为就是1个自增操作
,但从底层的汇编语言
层面上讲,i++却是一个包含3个指令
的非原子操作
:
(1)从内存中加载i的值(get)
(2)对i进行递增(modify)
(3)把i的值重新写回内存(set)
当结果i=1时,是因为线程1
和线程2并行执行
,同时
从内存中加载
到i=0,那么进行自增后i=1
,再次重新写入到内存
当中也就得到了i=1;如果想要得到i=2
,就必须加锁
,使得2个线程串行执行
。
下面我们依次从功能
、性能体现
和锁特性
层面上分别介绍各种类型的锁:
锁的分类
1 功能层面 : 共享锁(读锁)、排它锁(写锁)
共享锁
:同一时刻允许多线程同时抢占到锁。
排它锁
:同一时刻仅允许一个线程访问到共享资源。
2 性能层面:乐观/悲观锁、偏向锁/轻量级锁/重量级锁
加锁
使得多线程
从并行执行
变为串行执行
,而从效率上
讲,并行
肯定要优于串行
,故而会带来性能问题
,而在实际场景下,我们往往需要同时兼顾安全性和性能
。
同样举一个例子,比如我们商品的库存数量的变动,既要保证安全性,又要同时保证性能,应该如何去优化?
一般我们会从以下几个方面去优化:
(1)锁粒度优化。
即缩小锁的范围
,保证锁竞争范围在目标的需求范围内
即可。
(2)无锁化编程(乐观锁)。
乐观锁没有加锁
,它是通过数据的版本
来控制多线程并发时数据修改
的安全性
。
也可以通过CAS锁
(保证原子性和可见性)
(3)偏向锁/轻量级锁/重量级锁(减少锁竞争)
首先,性能体现在如下3个方面:
1.竞争同步状态时,涉及到上下文切换(用户态与内核态切换)
2.线程阻塞/唤醒
3.并行到串行的改变
那么该如何优化性能?
由于1和3方面不好去改变,对于1,当竞争同步状态时必然涉及上下文切换,这是内核层面上的我们很难去处理;对于3,并行到串行保证了线程安全性;因此我们只能通过2来去做优化:
如上图,假设线程2先拿到了锁
,此时线程1会进入阻塞
状态,需要等到线程2释放锁
之后,唤醒线程1
才能拿到锁
;此时我们便可以引入自旋锁(也叫轻量级锁),不必让线程1进入阻塞,而是在阻塞前让线程1进入一个循环不断重试去竞争该锁
。
考虑另外一种状况,加了锁的代码本身不存在竞争
,此时便引入偏向锁的概念: 当线程1进入锁
时,如果当前不存在竞争
,那么就会把该锁偏向于线程1
,待线程1下次进入时
,就不再需要
再去竞争该锁
。
(4)锁消除/锁膨胀(编译器层面优化)
锁消除
:代码本身不存在安全问题
,加了锁
之后JVM去编译时
发现该锁导致了无效竞争
,那么就会把该锁消除掉
.
锁膨胀
:控制的锁的粒度太小
了,导致频繁加锁、释放
,频繁去竞争,从而JVM会把锁的范围扩大
.
(5)读写锁(读多写少的情况下)
读操作不会影响数据的准确性
,因为它不会修改数据
;
假设有一个方法内大部分操作
都是去查询
,那么就不需要加锁
,这就是一种优化。
针对读多写少的场景
下,读和读不去竞争锁
,而读和写、写和写去竞争锁
,这也是一种优化。
(6)公平锁/非公平锁
默认情况下锁都是非公平
的,非公平锁的性能更好
,因为其减少了线程的阻塞和唤醒
。
(7)悲观锁
认为所有访问
都是非安全
的,所以必须要通过加锁
来去访问
总结:
偏向锁
:不存在竞争
轻量级锁
:存在轻微竞争
重量级锁
:正常加锁时实现的方式
3 锁特性层面:重入锁、分布式锁
(1)重入锁(避免死锁的一种设计)
当一个线程抢占到锁
,在释放之前
,再次去竞争同一把锁
时,无需阻塞等待,而是直接进入重试
。
(2)分布式锁(用于解决分布式架构下的力粒度问题)
Sychronized
解决了Java并发里的线程维度
上的安全问题,而分布式锁
则是用于解决进程维度
上的安全问题。