写点什么

闭嘴,别再问什么是锁了

发布于: 2020 年 12 月 17 日
闭嘴,别再问什么是锁了

欢迎搜索关注“程序员老猫”公众号

不积跬步,无以至千里;不积小流,无以成江海。

希望你我一同每日进步一点点。



目录

  • 前言

  • Java锁的简单入门

- 生活例子

- 抽象成代码

  • Java锁的解决常见方案

- 乐观锁和悲观锁

- 公平锁和非公平锁

  • 总结



前言

锁在Java中是一个非常重要的概念,在当今的互联网时代,尤其在各种高并发的情况下,我们更加离不开锁。那么到底什么是锁呢?在计算机中,锁(lock)或者互斥(mutex)是一种同步机制,用于在有许多执行线程的环境中强制对资源的访问限制。锁可以强制实施排他互斥、并发控制策略,下面我们正式进入锁的专题中。



Java锁的简单入门

生活例子

大家都去超市买东西,如果我们带了包的话,要放到储物柜。我们再把这个例子极端一下,假如柜子只有一个,那么此时同时来了三个人A、B、C都要往这个柜子里放东西。那么这个场景就是一个多线程,多线程自然也就离不开锁。简单示意图如下

储物柜模型



A、B、C都要往柜子里面放东西,可是柜子只能存放一个东西,那么怎么处理?这个时候我们就引出了锁的概念,三个人中谁先抢到了柜子的锁,谁就可以使用这个柜子,其他的人只能等待。比如C抢到了锁,C就可以使用这个柜子,A和B只能等待,等到C使用完毕之后,释放了锁,AB再进行抢锁,谁先抢到了,谁就有使用柜子的权利。



抽象成代码



我们其实可以将以上场景抽象程相关的代码模型,我们来看一下以下代码的例子。



/**
* @author kdaddy@163.com
* @date 2020/11/2 23:13
*/
public class Cabinet {
//表示柜子中存放的数字
private int storeNumber;
public int getStoreNumber() {
return storeNumber;
}
public void setStoreNumber(int storeNumber) {
this.storeNumber = storeNumber;
}
}



柜子中存储的是数字。



然后我们把3个用户抽象成一个类,如下代码



/**
* @author kdaddy@163.com
* @date 2020/11/7 22:03
*/
public class User {
// 柜子
private Cabinet cabinet;
// 存储的数字
private int storeNumber;
public User(Cabinet cabinet, int storeNumber) {
this.cabinet = cabinet;
this.storeNumber = storeNumber;
}
// 表示使用柜子
public void useCabinet(){
cabinet.setStoreNumber(storeNumber);
}
}



在用户的构造方法中,需要传入两个参数,一个是要使用的柜子,另一个是要存储的数字。以上我们把柜子和用户都已经抽象完毕,接下来我们再来写一个启动类,模拟一下3个用户使用柜子的场景。



/**
* @author kdaddy@163.com
* @date 2020/11/7 22:05
*/
public class Starter {
public static void main(String[] args) {
final Cabinet cabinet = new Cabinet();
ExecutorService es = Executors.newFixedThreadPool(3);
for(int i= 1; i < 4; i++){
final int storeNumber = i;
es.execute(()->{
User user = new User(cabinet,storeNumber);
user.useCabinet();
System.out.println("我是用户"+storeNumber+",我存储的数字是:"+cabinet.getStoreNumber());
});
}
es.shutdown();
}
}



我们仔细的看一下这个main函数的过程



  • 首先创建一个柜子的实例,由于场景中只有一个柜子,所以我们只创建了一个柜子实例。

  • 然后我们新建了一个线程池,线程池中一共有三个线程,每个线程执行一个用户的操作。

  • 再来看看每个线程具体的执行过程,新建用户实例,传入的是用户使用的柜子,我们这里只有一个柜子,所以传入这个柜子的实例,然后传入这个用户所需要存储的数字,分别是1,2,3,也分别对应了用户1,2,3。

  • 再调用使用柜子的操作,也就是想柜子中放入要存储的数字,然后立刻从柜子中取出数字,并打印出来。



我们运行一下main函数,看看得到的打印结果是什么?



我是用户1,我存储的数字是:3
我是用户3,我存储的数字是:3
我是用户2,我存储的数字是:2



从结果中,我们可以看出三个用户在存储数字的时候两个都是3,一个是2。这是为什么呢?我们期待的应该是每个人都能获取不同的数字才对。其实问题就是出在"user.useCabinet();"这个方法上,这是因为柜子这个实例没有加锁的原因,三个用户并行执行,向柜子中存储他们的数字,虽然3个用户并行同时操作,但是在具体赋值的时候,也是有顺序的,因为变量storeNumber只有一块内存,storeNumber只存储一个值,存储最后的线程所设置的值。至于哪个线程排在最后,则完全不确定,赋值语句执行完成之后,进入打印语句,打印语句取storeNumber的值并打印,这时storeNumber存储的是最后一个线程锁所设置的值,3个线程取到的值有两个是相同的,就像上面打印的结果一样。



那么如何才能解决这个问题?这就需要我们用到锁。我们再赋值语句上加锁,这样当多个线程(此处表示用户)同时赋值的时候,谁能优先抢到这把锁,谁才能够赋值,这样保证同一个时刻只能有一个线程进行赋值操作,避免了之前的混乱的情况。



那么在程序中,我们如何加锁呢?



下面我们介绍一下Java中的一个关键字synchronized。关于这个关键字,其实有两种用法。



(1)synchronized方法,顾名思义就是把synchronize的关键字写在方法上,它表示这个方法是加了锁的,当多个线程同时调用这个方法的时候,只有获得锁的线程才能够执行,具体如下:



public synchronized String getTicket(){
return "hello";
}

以上我们可以看到getTicket()方法加了锁,当多个线程并发执行的时候,只有获得锁的线程才可以执行,其他的线程只能够等待。



(2) synchronized代码块。如下:

synchronized (对象锁){
……
}

我们将需要加锁的语句都写在代码块中,而在对象锁的位置,需要填写加锁的对象,它的含义是,当多个线程并发执行的时候,只有获得你写的这个对象的锁,才能够执行后面的语句,其他的线程只能等待。synchronized块通常的写法是synchronized(this),这个this是当前类的实例,也就是说获得当前这个类的对象的锁,才能够执行这个方法,此写法等同于synchronized方法。



回到刚才的例子中,我们又是如何解决storeNumber混乱的问题呢?咱们试着在方法上加上锁,这样保证同时只有一个线程能调用这个方法,具体如下。



/**
* @author kdaddy@163.com
* @date 2020/12/2 23:13
*/
public class Cabinet {
//表示柜子中存放的数字
private int storeNumber;
public int getStoreNumber() {
return storeNumber;
}
public synchronized void setStoreNumber(int storeNumber) {
this.storeNumber = storeNumber;
}
}



我们运行一下代码,结果如下



我是用户2,我存储的数字是:2
我是用户3,我存储的数字是:2
我是用户1,我存储的数字是:1



我们发现结果还是混乱的,并没有解决问题。我们检查一下代码



es.execute(()->{
User user = new User(cabinet,storeNumber);
user.useCabinet();
System.out.println("我是用户"+storeNumber+",我存储的数是:"+cabinet.getStoreNumber());
});



我们可以看到在useCabinet和打印的方法是两个语句,并没有保持原子性,虽然在set方法上加了锁,但是在打印的时候又存在了并发,打印语句是有锁的,但是不能确定哪个线程去执行。所以这里,我们要保证useCabinet和打印的方法的原子性,我们使用synchronized块,但是synchronized块里的对象我们使用谁的?这又是一个问题,user还是cabinet?回答当然是cabinet,因为每个线程都初始化了user,总共有3个User对象,而cabinet对象只有一个,所以synchronized要用cabine对象,具体代码如下



/**
* @author kdaddy@163.com
* @date 2020/12/7 22:05
*/
public class Starter {
public static void main(String[] args) {
final Cabinet cabinet = new Cabinet();
ExecutorService es = Executors.newFixedThreadPool(3);
for(int i= 1; i < 4; i++){
final int storeNumber = i;
es.execute(()->{
User user = new User(cabinet,storeNumber);
synchronized (cabinet){
user.useCabinet();
System.out.println("我是用户"+storeNumber+",我存储的数字是:"+cabinet.getStoreNumber());
}
});
}
es.shutdown();
}
}



此时我们再去运行一下:



我是用户3,我存储的数字是:3
我是用户2,我存储的数字是:2
我是用户1,我存储的数字是:1



由于我们加了synchronized块,保证了存储和取出的原子性,这样用户存储的数字和取出的数字就对应上了,不会造成混乱,最后我们用图来表示一下上面例子的整体情况。

最终模型



如上图所示,线程A,线程B,线程C同时调用Cabinet类的setStoreNumber方法,线程B获得了锁,所以线程B可以执行setStore的方法,线程A和线程C只能等待。



上面的场景以及例子,我们可以了解多线程情况下,造成的变量值前后不一致的问题,以及锁的作用,在使用了锁以后,可以避免这种混乱的现象



Java锁的解决常见方案

乐观锁和悲观锁



乐观锁



老猫相信,很多的技术人员首先接触到的就是乐观锁和悲观锁。老猫记得那时候是在大学的时候接触到,当时是上数据库课程的时候。当时的应用场景主要是在更新数据的时候,当然多年工作之后,其实我们也知道了更新数据也是使用锁非常主要的场景之一。我们来回顾一下一般更新的步骤:



  1. 检索出需要更新的数据,提供给操作人查看。



  1. 操作人员更改需要修改的数值。



  1. 点击保存,更新数据。



这个流程看似简单,但是如果一旦多个线程同时操作的时候,就会发现其中隐藏的问题。我们具体看一下:



  1. A检索到数据;

  2. B检索到数据;

  3. B修改了数据;

  4. A修改了数据,是否能够修改成功呢?



上述第四点A是否能够修改成功当然要看我们的程序如何去实现。就从业务上来讲,当A保存数据的时候,最好的方式应该系统给出提示说“当前您操作的数据已被其他人修改,请重新查询确认”。这种其实是最合理的。



那么这种方式我们该如何实现呢?我们看一下步骤:



  1. 在检索数据的时候,我们将相关的数据的版本号(version)或者最后的更新时间一起检索出来。

  2. 当操作人员更改数据之后,点击保存的时候在数据库执行update操作。

  3. 当执行update操作的时候,用步骤1检索出的版本号或者最后的更新时间和数据库中的记录做比较;

  4. 如果版本号或者最后更新时间一致,那么就可以更新。

  5. 如果不一致,我们就抛出上述提示。



其实上述流程就是乐观锁的实现思路。在Java中乐观锁并没有确定的方法,或者关键字,它只是一个处理的流程、策略或者说是一种业务方案。看完这个之后我们再看一下Java中的乐观锁。



乐观锁,它是假设一个线程在取数据的时候不会被其他线程更改数据。就像上述描述类似,但是只有在更新的时候才会去校验数据是否被修改过。其实这种就是我们经常听到的CAS机制,英文全称(Compare And Swap),这是一种比较交换机制,一旦检测到有冲突。它就会进行重试。直到最后没有冲突为止。



乐观锁机制图示如下:



乐观锁机制



下面我们来举个例子,相信很多同学都是C语言入门的编程,老猫也是,大家应该都接触过i++,那么以下我们就用i++做例子,看看i++是否是线程安全的,多个线程并发执行的时候会存在什么问题。我们看一下下面的代码:



/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private int i=0;
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
test.i++;
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}



上面的程序中,我们用50个线程同时执行i++程序,总共执行5000次,按照常规的理解,得到的应该是5000,但是我们连续运行三次,得到的结果如下:



执行完成后,i=4975
执行完成后,i=4955
执行完成后,i=4968



(注:可能有小伙伴不清楚CountDownLatch,简单说明一下,该类其实就是一个计数器,初始化的时候构造器传了5000表示会执行5000次, 这个类使一个线程等待其他线程各自执行完毕后再执行,cdl.countDown()这个方法指的就是将构造器参数减一。具体的可以自行问度娘或者私信老猫,在此老猫不展开)



从上面的结果我们可以看到,每次结果都不同,反正也不是5000,那么这个是为什么呢?其实这就说明i++程序并不是一个原子性的,多线程的情况下存在线程安全性的问题。我们可以将详细执行步骤进行一下拆分。



  1. 从内存中取出i的值

  2. 将i的值+1

  3. 将计算完毕的i重新放入到内存中



其实这个流程和我们之前说到的数据的流程是一样的。只不过是介质不同,一个是内存,另一个是数据库。在多个线程的情况下,我们想象一下,假如A线程和B线程同时同内存中取出i的值,假如i的值都是50,然后两个线程都同时进行了+1的操作,然后在放入到内存中,这时候内存的值是51,但是我们期待的是52。这其实就是上述为什么一直无法达到5000的原因。那么我们如何解决这个问题?其实在Java1.5之后,JDK的官网提供了大量的原子类,这些类的内部都是基于CAS机制的,也就是说使用了乐观锁。我们更改一下代码,如下:



/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private AtomicInteger i= new AtomicInteger(0);
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
test.i.incrementAndGet();
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}



此时我们得到的结果如下,执行三次:



执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000



结果看来是我们所期待的,以上的改造我们可以看到,我们将原来int类型的变量更改成了 AtomicInteger,该类是一个原子类属于concurrent包(有兴趣的小伙伴可以研究一下这个包下面的一些类)我们将原来的i++的地方改成了test.i.incrementAndGet(),incrementAndGet这个方法采用得了CAS机制。也就是说采用了乐观锁,所以我们以上的结果是正确的。



我们对乐观锁进行一下总结,其实乐观锁就是在读取数据的时候不加任何限制条件,但是在更新数据的时候,进行数据的比较,保证数据版本的一致之后采取更新相关的数据信息。由于这个特点,所以我们很容易可以看出乐观锁比较试用于读操作大于写操作的场景中。



悲观锁



我们再一起看一下悲观锁,也是通过这个例子来说明一下。悲观锁其实和乐观锁不同,悲观锁从读取数据的时候就显示地去加锁,直到数据最后更新完成之后,锁才会被释放。这个期间只能由一个线程去操作。其他线程只能等待。其实上一篇文章中我们就用到了 synchronized关键字 ,其实这个关键字就是悲观锁。与其相同的其实还有ReentrantLock类也可以实现悲观锁。那么以下我们再使用synchronized关键字 和 ReentrantLock进行悲观锁的改造。具体代码如下:



/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private int i= 0;
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
synchronized (test){
test.i++;
}
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}



以上我们的改动就是新增了synchronized代码块,它锁住了test的对象,在所有的线程中,谁获取到了test的对象,谁就能执行i++操作(此处锁test是因为test只有一个)。这样我们采用了悲观锁的方式我们的结果当然也是OK的执行完毕之后三次输出如下:



执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000



再看一下ReentrantLock类实现悲观锁,代码如下:



/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private int i= 0;
Lock lock = new ReentrantLock();
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
test.lock.lock();
test.i++;
test.lock.unlock();
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}



用法如上,其实也不用太多介绍,小伙伴们看代码即可,上述通过lock加锁,通过unlock释放锁。当然我们三次执行完毕之后结果也是OK的。



执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000



三次执行下来都是5000,完全没有问题。



我们再来总结一下悲观锁,悲观锁其实就是从读取数据的那一刻就加了锁,而且在更新数据的时候,保证只有一个线程在执行更新操作,并没有如乐观锁那种进行数据版本的比较。所以可想而知,悲观锁适用于读取相对少,写相对多的操作中。



公平锁和非公平锁



前面和小伙伴们分享了乐观锁和悲观锁,下面我们就来从另外一个维度去认识一下锁。公平锁和非公平锁。顾名思义,公平锁在多线程的情况下,对待每个线程都是公平的,然而非公平锁确是恰恰相反的。就光这么和小伙伴们同步,估计大家还会有点迷糊。我们还是以之前的储物柜来说明,去超市买东西,储物柜只有一个,正好有A、B、C三个人想要用柜子,这时候A来的比较早,所以B和C自觉进行排队,A用完之后,后面排着队的B才会去使用,这就是公平锁。在公平锁中,所有的线程都会自觉排队,一个线程执行完毕之后,后续的线程在依次进行执行。



然而非公平锁则不然,当A使用完毕之后,A将钥匙往后面的一群人中一丢,谁先抢到,谁就可以使用。我们大概可以用以下两个示意图来体现,如下:



公平锁



对应的多线程中,线程A先抢到了锁,A就可以执行方法,其他的线程则在队列中进行排队,A执行完毕之后,会从队列中获取下一个B进行执行,依次类推,对于每个线程来说都是公平的,不存在后加入的线程先执行的情况。



非公平锁



多线程同时执行方法的时候,线程A抢到了锁,线程A先执行方法,其他线程并没有排队。当A执行完毕之后,其他的线程谁抢到了锁,谁就能执行方法。这样就可能存在后加入的线程,反而先拿到锁。



关于公平锁和非公平锁,其实在我们的ReentrantLock类中就已经给出了实现,我们来看一下源码:



/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}



该类中有两个构造方法,从字面上来看默认的构造方法中 sync = new NonfairSync()是一个非公平锁。再看看第二个构造方法,需要传入一个参数,true是的时候是公平锁,false的时候是非公平锁。以上我们可以看到sync有两个实现类,分别是FairSync以及NonfairSync,我们再来看一下获取锁的核心方法。



获取公平锁:



@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}



非公平锁:



@ReservedStackAccess
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}



以上两个方法,我们很容易就能发现唯一的不同点就是 hasQueuedPredecessors() 这个方法,从名字上来看就知道这个是一个队列,因此我们也就可以推断,公平锁是将所有的线程放到一个队列中,一个线程执行完成之后,从队列中区所下一个线程。而非公平锁则没有这样的队列。这些就是公平锁和非公平锁的实现原理。这里也不去再深入去看源码了,我们重点是了解公平锁和非公平锁的含义。我们在使用的时候传入true或者false即可。



总结



其实在Java中锁的种类非常的多,在此老猫只介绍了常用的几种,有兴趣的小伙伴其实还可以去钻研一下独享锁、共享锁、互斥锁、读写锁、可重入锁、分段锁等等。



乐观锁和非乐观锁是最基础的,我们在工作中肯定接触的也比较多。



从公平非公平锁的角度,大家如果用到ReetrantLock其实默认的就是用到了非公平锁。那什么时候用到公平锁呢?其实业务场景也是比较常见的,就是在电商秒杀的时候,公平锁的模型就被套用上了。



再往下写估计大家就不想看了,所以此篇幅到此结束了,后续陆陆续续会和大家分享分布式锁的演化过程,以及分布式锁的实现,敬请期待。



发布于: 2020 年 12 月 17 日阅读数: 483
用户头像

成长源于积累,每天进步一点点 2019.10.12 加入

爱生活,爱技术。 个人纯技术干货微信公众号“程序员老猫”。

评论

发布
暂无评论
闭嘴,别再问什么是锁了