微信公众号: 码工是小希
关注选择“星标”,重磅干货,第一时间送达!
[如果你觉得文章对你有帮助,欢迎 关注,再看,转发,点赞]
1 开锁三部曲-必备知识
1.1 什么是锁?
在计算机的世界里,锁(lock
)或者是互斥(mutexd
)都是一种同步机制,用于在有许多执行线程的环境中强制去对资源的访问进行限制的。锁目的就是强制实施互排他性、实现并发控制的一种策略。
1.2 两大门派
锁主要是两类:
- 内置的锁
- concurrent实现的一系列锁
这是为啥呀? 因为在java中一切都是对象,而java对每个对象都内置一把锁的,你可以叫它对象锁或者内部锁,它通过 synchronized
来是完成相应的锁操作。但是由于 synchronized
它实现上有些缺陷并且在并发场景的复杂性,有人开发出了一种叫显式的锁,这就其实是种折中的办法,当然就对应隐式锁,后文中我们就会提到。而这些锁都是 java.util.concurrent.locks.Lock
派生出来的。当然目前已经内置到了JDK1.5及之后的版本了。
1.3 要知道的同步和异步
- 通常用来形容一次方法调用:
- 1.同步:同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。
- 2.异步:更像一个消息传递,一旦开始,方法调用就会立即返回,调用者可以继续后续的操作。如果异步调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。
1.4 线程是否需要对资源加锁 ?
Java 按照是否对资源加锁分成乐观锁和悲观锁,其实乐观锁和悲观锁并不是一种真实存在的锁,而是一种设计思想,乐观锁和悲观锁对于理解 Java
多线程和数据库来说是至关重要的,下面就来说下这两种实现方式的区别和优缺点
2 乐观锁 VS 悲观锁
2.1 悲观锁
悲观锁是一种悲观思想,它总认为最坏的情况可能会发生,它认为数据很可能总被其他人修改,所以悲观锁在持有数据的时候总会把资源或者数据锁住,这样其他线程想要请求这个资源的时候就会阻塞掉,直到等到悲观锁把资源释放为止。传统的关系型数据库比如 MYSQL
里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。悲观锁的实现往往依靠数据库本身。
它的调用方式
//------------------------悲观锁的调用方式------------------------
// synchronized
public synchronized void testMethod() {
//操作同步资源
}
// Reentrank
private ReentrankLock lock = new ReentrankLock(); //需要保证多个线程使用同一个锁
public void modifyPublicResources() {
lock.lock();
}
原理所示:
Java 中的 Synchronized
和 ReentrantLock
等独占锁(或者叫排他锁)也是一种悲观锁思想的实现,因为 Synchronzied
和 ReetrantLock
不管是否持有资源,它都会尝试去加锁,生怕自己心爱的宝贝被别人抢走一样!
2.2 乐观锁
乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据有没有被被别人改过(具体如何判断我们下面再说)。乐观锁的实现方案一般来说有两种:版本号机制 和 CAS实现(后面会提到)。乐观锁多适用于多读的应用类型,这样可以提高吞吐量的哦。
乐观锁的调用方式
//----------------------乐观锁的调用方式-----------------------
private AtomicInteger atomicInteger = new AtomicInteger(); //需要保证多个线程使用同一个AtomicInteger
atomicInteger.incrementAndGet();//执行自增1
原理所示:
2.3 什么叫版本号控制呢?
版本号机制是在数据表中加上一个 version
字段 来实现的,它表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1
,当线程A要更新数据时,在读取数据的同时也会读取 version
值,提交更新时,若刚才读取到的 version
值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功。
我们以金融系统为例,简单说下这个过程。
成本系统中有一个数据表,表中有两个字段分别是金额
和version
,金额的属性是能够实时变化,而version
表示的是金额每次发生变化的版本,一般的策略是,当金额发生改变时,version
采用递增的方法每次都在上一个版本号的基础上+ 1
。
在了解了基本情况和基本信息之后,我们来看一下过程:公司收到回款后,需要把这笔钱放在金库里面,假如金库中现在有100元钱;
下面开启事务一:
当男柜员执行回款写入操作前,他会直接先查看(读)一下金库中还有多少钱,此时读到金库中有 100元
,可以执行写操作,并把数据库中的钱更新为 120元
,提交事务,金库中的钱由 100 -> 120
,version
的版本号由 0 -> 1
。
开启事务二:
女柜员收到给员工发工资的请求,需要先执行读请求,查看金库中的钱还剩多少,此时的版本号是多少,然后她从金库中取出员工的工资进行发放,提交事务,成功后版本 + 1
,此时版本由 1 -> 2
。
上面两种情况是最乐观的情况,上面的两个事务都是顺序执行的,也就是事务一和事务二是互不干扰的,那么事务要并行执行会如何呢?
现在事务一又开启:
男柜员先执行读操作,取出金额和版本号,执行写操作
beginupdate 表 set 金额 = 120,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 120,版本号为 1,事务还没有提交
事务二又开启:
女柜员先执行读(查看)操作,取出金额和版本号,执行写操作
beginupdate 表 set 金额 = 50,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 50,版本号变为 1,事务也未提交
现在我们提交事务一,金额改为 120,版本变为 1,提交事务。理想情况下应该变为 金额 = 50,版本号 = 2,但是实际上事务二的更新是建立在金额为 100 和 版本号为 0 的基础上的,所以事务二不会提交成功,应该重新读取金额和版本号,再次进行写操作。
这样做的目的,就避免了女柜员 用 version = 0
的旧数据修改的结果覆盖男操作员操作结果的可能。
beginupdate 表 set 金额 = 120,version = version + 1 where 金额 = 100 and version = 0
3 CAS你了解吗
Compare And Swap(比较与交换),是种没锁的算法,也就是在不适用锁(线程没有阻塞)的情况下实现多线程的变量同步。说人话,就是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替换当前变量的值,你懂了没?
CAS算法涉及到三个操作数:
- 需要读写的内存值V
- 进行比较的值A
- 要写入的新值B
如果 V 的值等于 A 时,CAS 通过原子方式用新值去更新 V 的值(“比较+更新”整体是一个原子操作哈),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。
其中 java.util.concurrent
包中的原子类,就是通过 CAS 来实现了乐观锁,那么我们看下 原子类AtomicInteger 的源码,看一下AtomicInteger的定义:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
//安装程序使用Unsafe.compareAndSwapInt进行更新
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long value0ffset;
static {
try {
valueOffset = unsafe.objectFileldOffset
(AtomicInteger.class.getDeclaredField(name:"value"));
}catch (Exception ex) {
throw new Error(ex);
}
}
private volatile int value;
}
定义各属性的作用:
unsafe
:获取并操作内存的数据。
valueOffset
:存储value在AtomicInteger中的偏移量。
value
:存储AtomicInteger的int值,该属性需借助volatile关键字保证其在线程间是可见的。
JDK1.5之后,你可以用到 java.util.concurrent.atomic 包中的一些原子类来使用CPU中的这些功能:
private AtomicBoolean locked = new AtomicBoolean(false);
public boolean lock() {
return locked.compareAndSet(false, true);
}
locked
不再是 boolean类型而是 AtomicBoolean。 这个类中有一个 compareAndSet()
方法,根据咱们上面提到的定义,它使用一个期望值和AtomicBoolean实例的值比较,若和两者相等,则使用一个新值替换原来的值。在这个例子中,它比较 locked的值 和 false,如果 locked 的值为 false,则把修改为true。如果值被替换了,compareAndSet()返回true,否则,返回false。
哪如果是情况二的话呢
循环时间长开销大:
CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。但还是有办法可以通过修改JVM参数来优化自旋次数,
-XX:PreBlockSpin=10 默认次数是10次
只能保证一个共享变量的原子操作:
对一个共享变量执行操作,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。那你可能要说
这个你把心放在肚子里吧,Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
可以看出CAS在整个J.U.C
的最底层,CAS
为 AQS
和 各种工具包都提供了支持的
CAS的ABA问题
进程P1 在共享变量中读到值为A
,P1 被抢占了,进程P2执行,P2 把共享变量里的值从A
改成了B
,再改回到A
,此时被P1抢占。P1 回来看到共享变量里的值没有被改变,于是继续执行。
哈哈你可能没有看懂没得关系,我给你个活生生的例子:你拿着一个装满钱的手提箱在飞机场,此时过来了一个火辣性感的美女,比如这位女同志:
她很暖昧地挑逗着你,并趁你不注意的时候,把用一个一模一样的,手提箱和你那装满钱的箱子调了个包,然后就离开了,你看到你的手提箱还在那,于是就提着手提箱去赶飞机去了,这就是上面原理的还原。
4 进阶必备知识
4.1 线程阻塞的代价
我们其实要明白java的线程是映射到操作系统原生线程之上的,如果要 阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源的,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也肯定 需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间:
如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然是非常糟糕的。synchronized
会导致争不到锁的线程进入阻塞状态,所以说它是java
语言中一个重量级的同步操纵,它也被称为重量级锁,为了缓解上述性能问题,JVM
从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁,这咱们只是简单了解下,我在下篇会详细来讲。
所以java线程切换的代价你明白了没,是理解java中各种锁的优缺点的基础之一。
4.2 粒度Granularity
在引入锁粒度之前,需要了解关于锁的三个概念:
- 1、
锁开销
:锁占用内存空间、 cpu初始化和销毁锁、获取和释放锁的时间。程序使用的锁越多,相应的锁开销越大
- 2、
锁竞争
:一个进程或线程试图获取另一个进程或线程持有的锁,就会发生锁竞争。锁粒度越小,发生锁竞争的可能性就越小
- 3、
死锁
:至少两个任务中的每一个都等待另一个任务持有的锁的情况锁粒度是衡量锁保护的数据量大小,如图:
你在生活中就碰到这样:
所以在所粒度这回事上,你 只能选择一种哈,通常选择粗粒度的锁(锁的数量少,每个锁保护大量的数据),在当单进程访问受保护的数据时锁开销小,但是当多个进程同时访问时性能很差。因为增大了锁的竞争。相反,使用细粒度的锁(锁数量多,每个锁保护少量的数据)增加了锁的开销但是减少了锁竞争。譬如数据库中,锁的粒度比较小的有表锁、页锁、行锁、字段锁、字段的一部分锁,这里就不展开喽;
4.3 markword
先搞明白啥意思,markword
是 java
对象数据结构中的一部分,要详细了解java对象的结构我也专门出一期文章
,期待兄弟们的关注呀。这里只做markword的简单描述,因为对象的 markword
和java各种类型的锁密切相关;如果展开那说的东西太多了。
markword
数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,它的最后 2bit 是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:32位
虚拟机在不同状态下 markword结构 如下图所示:
了解了markword结构,有助于我们后面理解java锁加锁解锁的过程;
4.4 临界区 -就像洗手间
它有些时候也被叫做关键段。无所谓,但是你要记住,每个进程中访问临界资源的那段代码称为临界区(Critical Section)(临界资源是一次仅允许一个进程使用的共享资源)。每次只准许一个进程进入临界区,进入后不允许其他进程进入。不论是硬件临界资源,还是软件临界资源,多个进程必须互斥地对它进行访问。线程同步问题可以通过临界区的方式实现:设置一个令牌(厕纸),一个线程申请到令牌就让他执行,其他线程申请令牌(厕纸)时,拿不到令牌(厕纸)就让这个线程自己进入阻塞状态,等待系统重新唤醒线程。唤醒后,再尝试获取令牌,直到获取到令牌然后执行正常流程,最后正常退出。
临界区本质上是通过线程切换来实现的线程互斥的效果,但是它有缺点:
- 线程切换本身需要消耗一定的时间,效率低。
- 临界区粒度太大,当我们需要对一小段代码要求互斥,这段代码可能只需要几纳秒就执行完毕了,但线程切换就可能需要20毫秒,得不偿失啊。
4.5 信号量 -计时报警器
它是控制系统耗时资源的访问,是保护临界区的一种常用方法,一般我们要初始设置了一个公平的信号量,线程在使用时需要申请,用完之后需要释放。它的用法和自旋锁是类似的,但是也有不同之处的,与自旋锁不同的是当获取不到信号量的时候,进程不会原地打转,而是进入休眠状态;
信号量Semaphore 的使用流程如下:
一般先设置公平的信号量-->线程在使用时需要进行require
申请-->若可以申请到,则执行自己的逻辑-->执行完成后,需要释放信号量-->若未申请到,则可以阻塞,直到申请到。
主要方法
信号量的部分常用方法如下:
- 1).信号量构造:public Semaphore(int permits, boolean fair)
- 2).获取信号量:public void acquire();acquireUninterruptibly();tryAcquire() ;tryAcquire(long timeout, TimeUnit unit)
- 3).释放信号量:release();release(int permits)
实例代码
下面我们来通过初始化一个3容量的信号量,我们用100个线程去获取这个信号量,然后每次申请一个信号量,当用完之后,我们就释放一个:
package com.yang.concurrent;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.stream.IntStream;
public class SemaphoreDemp {
static Semaphore semaphore =new Semaphore(3,true);
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName()+"等待获取信号量");
semaphore.acquire();
System.out.println(Thread.currentThread().getName()+"取到了信号量");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
System.out.println(Thread.currentThread().getName()+"释放信号量");
semaphore.release();
}
}
};
executorService.submit(runnable);
}
executorService.shutdown();
}
}
4.6 互斥体(mutex) --就像磁铁
说白了就是实现了“互相排斥
”(mutual exclusion)同步的一种简单抽象形式(所以才叫互斥体(mutex))。互斥体禁止多个线程同时进入受保护的代码“临界区”;
1)定义并初始化互斥体:
struct mutex my_mutex;
mutex_init()&my_mutex;
2)获取互斥体:
void mutex_lock(struct mutex *lock); // 不可中断
int mutex_lock_interruptible(struct mutex *lock); // 可中断
int mutex_trylock(struct mutex *lock);
3)释放互斥体:
void mutex_unlock(struct mutex *lock);
mutex的使用方法和信号量用于互斥的场合完全一样:
struct mutex my_mutex; /*定义mutex*/
mutex_init(&my_mutex); /*初始化mutex*/
mutex_lock(&my_mutex); /*获取mutex*/
... /*临界资源*/
mutex_unlock(&my_mutex); /*释放mutex*/
小结:
互斥体是进程级别的,用在多个进程之间对资源的互斥。当资源竞争失败时,会发生进程上下文切换,当前进程进入睡眠状态,CPU将运行其它进程。但是上下文切换的开销也很大,因此:
- 只有当进程占用资源时间较长时,用互斥体才是较好的选择。
- 当要保护的临界区访问时间较短时,用自旋锁更加方便。
估计你还是听的懵,我再解释下当锁不能被获取到的时候,使用互斥体的开销是进程上下文切换时间, 使用自旋锁的开销是等待获取自旋锁,这是由临界区执行时间决定。假如临界区比较小,宜使用自旋锁,假如临界区很大,应使用互斥体。
互斥体所保护的临界区可包含可能引起阻塞的代码, 而自旋锁则绝对要避免用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
互斥体存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在互斥体和自旋锁之间只能选择自旋锁。当然,如果一定要使用互斥体,则只能通过mutex_trylock()方式进行,不能获取就立即返回以避免阻塞。
5 修饰方法
1)、修饰实例方法:作用于当前实例加锁,在进入同步代码方法时先获取当前实例锁
2)、修饰静态方法:作用于当前类对象锁,在进入同步代码方法时先获取当前对象锁(类.class)
5.1 修饰实例方法:
见名知意,就是修饰类中的实例方法,并且默认是当前对象作为锁的对象,而一个对象只有一把锁,所以同一时刻只能有一个线程执行被同步的方法,等到线程执行完方法后,其他线程才能继续执行被同步的方法。
代码举例:
public class SynchronizedTest implements Runnable {
public static int count = 0;
@Override
public void run() {
for (int i = 1; i <= 1000000; i++) {
add();
}
}
public synchronized void add() {
++count;
}
public static void main(String[] args) throws Exception {
SynchronizedTest test = new SynchronizedTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count); //2000000
}
}
错误代码:对同一共享资源使用不同实例锁
public class SynchronizedTest implements Runnable {
private Object object;
public SynchronizedTest(Object object) {
this.object = object;
}
public static int count = 0;
@Override
public void run() {
synchronized (object) {
for (int i = 1; i <= 1000000; i++) {
add();
}
}
}
public static void add() {
++count;
}
public static void main(String[] args) throws Exception {
Object object1 = new Object();
Object object2 = new Object();
SynchronizedTest firstLock = new SynchronizedTest(object1);
SynchronizedTest secondLock = new SynchronizedTest(object2);
Thread t1 = new Thread(firstLock);
Thread t2 = new Thread(secondLock);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);//小于 20000000
}
}
运行之后,你会发现结果永远都小于 2000000
,说明 synchronized
没有起到同步的作用了,说明修饰实例方法只能作用实例对象,不能作用到类对象;
5.2 修饰静态方法:
public class SynchronizedTest implements Runnable {
public static int count = 0;
@Override
public void run() {
for (int i = 1; i <= 100000; i++) {
add();
}
}
public static synchronized void add() {
++count;
}
public static void main(String[] args) throws Exception {
SynchronizedTest firstLock = new SynchronizedTest();
SynchronizedTest secondLock = new SynchronizedTest();
Thread t1 = new Thread(firstLock);
Thread t2 = new Thread(secondLock);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);=2000000
}
}
作用与静态方法的时候,不管实例化多少个实例对象,结果等 2000000
,说明锁对象是当前类.class,有且仅有一把锁,最终结果和实际结果一致!
5.3 修饰代码块:正确案例
ppublic class SynchronizedTest implements Runnable {
private Object object;
public SynchronizedTest(Object object) {
this.object = object;
}
public static int count = 0;
@Override
public void run() {
synchronized (object) {
for (int i = 1; i <= 1000000; i++) {
add();
}
}
}
public static void add() {
++count;
}
public static void main(String[] args) throws Exception {
Object object = new Object();
SynchronizedTest firstLock = new SynchronizedTest(object);
SynchronizedTest secondLock = new SynchronizedTest(object);
Thread t1 = new Thread(firstLock);
Thread t2 = new Thread(secondLock);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);//= 20000000
}
}
它的错误案例:
public class SynchronizedTest implements Runnable {
private Object object;
public SynchronizedTest(Object object) {
this.object = object;
}
public static int count = 0;
@Override
public void run() {
synchronized (object) {
for (int i = 1; i <= 1000000; i++) {
add();
}
}
}
public static void add() {
++count;
}
public static void main(String[] args) throws Exception {
Object object1 = new Object();
Object object2 = new Object();
SynchronizedTest firstLock = new SynchronizedTest(object1);
SynchronizedTest secondLock = new SynchronizedTest(object2);
Thread t1 = new Thread(firstLock);
Thread t2 = new Thread(secondLock);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);//小于 20000000
}
}
代码块使用加 Synchronized
的时候,使用同一把锁,其他的线程就必须等待,这样也就保证了每次只有一个线程执行被同步的代码块。不是同一把同锁,无法达到共享资源同步结果;
![](https://gitee.com/Datalong/picture/raw/master/2021-9-9/1631171624117-image.png) ## 6 总结 本文系统讲解在计算机的世界里,锁到底是干啥用的?以及它简单的分类,它与咱们的并发的临界区,信号量这些非常重要的概念有机地结合在一起,还有CAS虽然这些都是小知识点,但是理解并发原理的第一步。好今天的就写到这里,下篇兄弟们就跟着晨希来逐个分析各种锁,以及在面试中有哪些应用场景。我是晨希,专注图解技术,每晚8:30与你不见不散!