【多线程进阶】锁的特性

目录

一、乐观锁&悲观锁

       对于乐观锁:     

       对于悲观锁:

二、公平锁&非公平锁

三、可重入锁&非可重入锁

四、读写锁&互斥锁

      互斥锁

    读写锁

      读写锁涉及的类:ReentrantReadWriteLock

       读写锁的优势:

五、轻量级锁&重量级锁

六、CAS

 ①基于CAS实现原子类

下面,来一段CAS的伪代码,来解释一下,为什么上述的过程没有出现线程安全问题:

②基于CAS实现自旋锁(实现一个类:SpainLock/伪代码)。

     属性:需要一个线程的引用,来确保是哪一个线程加锁的

     lock方法:

       Java当中的自旋锁 

  unlock方法

ABA问题

解决ABA问题


一、乐观锁&悲观锁

        判定一个锁是悲观锁还是乐观锁,主要还是站在锁冲突(锁竞争)发生的概率预测上面来进行判断的。

       对于乐观锁:     

        乐观锁默认锁竞争的情况不那么激烈

        总是假设是最好的情况,所以每次获取数据的时候都认为别人不会修改。

        实现的方式:自旋锁

        假定没有冲突,只有在更新数据时比较发现不一致的时候,则循环读取新的值进行更新。

        如果一致,那么就采用CAS的方式来更新。

       对于悲观锁:

        也叫做"独占锁"。总是假设是最坏的情况,每次当有线程访问的时候都会认为别人在修改这一个数据。因此当有两个以上的线程同时获取一把悲观锁的时候,就会产生锁冲突。

         Java当中的synchronized和ReentrantLock都是"悲观锁"。


二、公平锁&非公平锁

       给定一个场景:

       此时,三个线程t1,t2,t3同时竞争一把锁

        t1如果最先获取到锁,那么接下来在t1释放锁之前,t2,t3必须要阻塞等待t1释放锁。

        如图:

        

       可以看到,虽然t2,t3线程都没有获取到锁,但是t2比t3先开始阻塞等待。

       那么,当t1释放锁之后,t2和t3线程哪个优先可以获取到锁呢?这个就涉及到公平锁和非公平锁的差别。


       对于公平锁:会优先让t2线程获取到锁,也就是让最开始进行阻塞等待的线程优先获取到锁。

       对于非公平锁:  在t1释放锁之后,其余阻塞等待的线程都会继续重新竞争锁,不存在谁最先获取到锁的情况。

       其中,synchronized是非公平锁,ReentranctLock可以实现公平&非公平两种策略。

       对于公平锁来说,因为它需要确保其他线程都按照顺序再次获取锁。

       因此,公平锁需要一个队列来记录阻塞等待线程的等待顺序。


三、可重入锁&非可重入锁

        也在这篇文章当中介绍了。

        synchronized是可重入的
(1条消息) Java对于synchronized的初步认识_革凡成圣211的博客-CSDN博客https://blog.csdn.net/weixin_56738054/article/details/128062475?spm=1001.2014.3001.5502


四、读写锁&互斥锁

      互斥锁

        典型代表就是synchronized。

        提供了加锁、解锁两个操作。

        当一个线程获取到锁之后,其他的线程如果想加锁,那就会造成"阻塞等待"。无论其他线程对于加锁代码块的操作是否产生线程安全问题,都会产生阻塞等待。

         synchronized是典型的互斥锁

         关于synchronized的介绍,也已经在这一篇文章当中提及了:  
(1条消息) Java对于synchronized的初步认识_革凡成圣211的博客-CSDN博客https://blog.csdn.net/weixin_56738054/article/details/128062475?spm=1001.2014.3001.5502


    读写锁

      有关线程安全问题的分析,我们在上一篇文章当中也提到了。

       再简单回顾一下:如果多个线程同时针对某一变量进行修改操作,那么就会发生线程安全问题。此处的修改可以理解为"写"操作。

       但是,如果在多个线程仅仅只是读取某一个变量的值,不对这个变量进行修改,那么不存在线程安全问题。


      读写锁涉及的类:ReentrantReadWriteLock

        这个类当中,同时又提供了两个静态内部类:

        第一个是:ReentrantReadWriteLock.ReadLock

        这表示一个读锁,提供了lock,unlock两个方法,一个代表加锁,另外一个代表解锁。


        第二个是:ReentrantReadWriteLock.WriteLock writeLock

        这表示一个写锁,也提供了lock,unlock两个方法。


       读写锁的优势:

       而读写锁,恰好就是针对"写"这个操作来作文章。给"写",也就是修改的操作加锁,不给"读"的操作加锁。   

       也就是:

       加锁和加锁之间,不会产生锁冲突;

       加锁和加锁之间,产生锁冲突;

       加锁和加锁之间,产生锁冲突; 

       所以:

        如果代码当中的操作仅仅是读操作,那么只加读锁即可。

        如果代码当中的操作涉及写,那么加写锁即可。

  

        以看到,由于减小了读、读的锁冲突,相比于互斥锁,有效减少了锁的粒度 。


五、轻量级锁&重量级锁

     对于synchronized,它的轻量级锁,是基于自旋锁的方式实现的。

     对于synchronized,它的重量级锁,是基于挂起等待锁的方式实现的。

     下一篇文章,将详细介绍。


六、CAS

        含义:cas的全称为:compare and swap

       是并发编程当中一种常用的算法,它包含了下面三个参数:V,A,B。

        V表示要读写的内存位置,A表示旧的预期值(可以理解为原来的值),B表示一个新值。


        给定一个场景:

        如图,CPU寄存器当中有两个变量A=10,B=20,内存当中有一个地址V,里面保存了一个10。


    当内存当中的值(&V)旧的值(A)相同的时候,把新的值(B)写入到内存当中。

    伪代码: 其中address代表V的地址,A代表预期值(expectVal),也就是改动之前的值。

     B代表新的(swapvalue)值。

boolean CAS(address, expectVal,swapvalue){

  if(&address==expectedvalue){
     
   &address=swapvalue;
   return true;

}
   return false;

}

        以上看似简单的几行代码,也有几个比较特殊的地方:

        第一个特殊的地方:CAS这一系列代码是一条CPU指令。 

        那么,也就意味着:CAS操作是原子的。也就意味着:比较和交换的整个过程是原子的。

        也就意味着,一个线程在CAS的时候,另外的线程无法进入CAS的代码块。


 ①基于CAS实现原子类

      还是之前的文章当中提到的场景,让两个线程,分别对一个变量count各自自增50W次的场景。  

       根据之前的知识,可以判定,上述代码是存在线程安全问题的,因为count++这个操作不是原子性的。

       但是,如果把上述代码改成这样,引入了原子类(AtomicInteger)呢?     

      运行结果:可以看到这个时候,没有发生线程安全问题。

      


下面,来一段CAS的伪代码,来解释一下,为什么上述的过程没有出现线程安全问题:

class AtomicInteger{

    /**
     * val可以理解为V,也就是原来的内存当中的值
     */
     private int val;

     public int getAndIncrement(){
         //读取旧的值,也就是原来内存当中的值
         int oldVal=val;
         //oldValue为一个预期的值(A)
         //oldValue+1为目标值(B)
         //比较,如果oldValue的值和原来内存当中的值相同,那么就需要更改内存当中
         //val的值,然后返回true
         while (CAS(val,oldVal,oldVal+1)!=true){
             oldVal=val;
         }
         return oldVal;
     }
}

       第一步:使用一个oldValue变量,来保存原来的内存当中的值value。此处的oldValue可以理解为寄存器当中的值,线程工作内存当中的值,也就是预期值。


       第二步:已经进入到CAS方法当中了:

           判断内存当中的值val是否和寄存器当中的值相同,也就是valoldValue的值是否相同。

           这里有可能有个疑问:oldValue不是刚刚由val赋值过去的吗?但是为什么二者还会出现不相同的情况呢?

       正常情况下面,oldValue的值应该是和内存当中的val相同的。如果相同的话,那么就把oldValue的值+1,然后赋值给val(下划线部分的内容,都是在CAS方法内部完成的)

       然后,CAS方法返回true,不进入while循环

    

        但是,完全有可能在一个线程(thread1)进入了getAndIncrement()方法当中之后,被切出了CPU内核   

          此时,另外一个线程(thread2)修改了val的值,也就是原来内存当中的值。

          然后,当thread1重新回到cpu内核上面的时候,发现,val已经不再是自己的工作内存当中的oldValue了。    

       这个时候,由于val和oldValue的值不相同,因此CAS方法返回false,进入while循环当中,也就是CAS不成功,继续进行load。

       下面,模拟一下两个线程被cpu进行调度的过程。

时间轴thread1thread2
t1load:oldValue=val
t2load:oldValue=val
t3

CAS(val,oldValue,oldVal+1)

返回true,实现increment

t4返回自增过后的value
t5

CAS(val,oldValue,oldValue+1)

因为val和oldValue不相同,因此返回false,进入while循环,重新比较,直到CAS的返回值为true

t6CAS自增成功之后,返回true。

       可以看到,上述的过程当中,没有涉及到任何阻塞等待的情况。

       但是,出现了反复比较的情况。但是,只要不CAS成功,那么线程就会一直CAS,因此这种实现线程安全的方式也存在效率的问题。 


②基于CAS实现自旋锁(实现一个类:SpainLock/伪代码)。

     自旋锁不涉及任何的阻塞等待的操作。

     属性:需要一个线程的引用,来确保是哪一个线程加锁的

/**
  * 记录哪一个线程尝试获取这个自旋锁
 */
private Thread threadOwner=null;

     lock方法:

    /**
     * 加锁
     */
    public void lock(){
       while (CAS(this.threadOwner,null,Thread.currentThread())!=true){
           
       }
    }

       以上CAS的工作过程,是这样的:

       首先,判断threadOwner是否为null。如果为null,那么说明此时自旋锁还没有被占用。

       可以把当前线程,也就是Thread.currentThread()赋值给threadOwner。这个时候,threadOwner就已经被赋值了。

       当其他线程再次CAS的时候,就会因为threadOwner!=null,因此就不会产生赋值,因此返回false,重新进入while循环进行CAS判断

时间轴线程
t1进入lock方法
t2

进行CAS操作:threadOwner,null,Thread.currentThread()

说明:

如果threadOwner为null,说明这把锁没有被占有,当前线程(t1)可以占用这把锁,因此把当前线程的引用赋值给threadOwner

此时,如果其他线程(t2)过来,那么就会因为threadOwner不为null而进入循环,反复比较,直到二者相同。

       Java当中的自旋锁 

        ReentrantLock就是一个典型的自旋锁。从上述的代码也可以看出来,线程没有发生阻塞等待的情况,也就是自旋锁不会造成阻塞等待的情况。


  unlock方法

     让threadOwner重新变为null。

     这样,其他线程再次CAS的时候,threadOwner就不再是null了,不用进入CAS。

 public void unlock(){
        threadOwner=null;
    }

ABA问题

 之前提到了,对于CAS指令:

       可以看到,在if语句当中,对于&address和expectedvalue两个值,也就是原来内存当中的值期待的值,当这两个值相同的时候,才会把内存当中的值切换为swapvalue,也就是目标值。


        但是,对于判断相等这个操作,很有可能是这样的一种场景:

        当线程thread把value加载到自己的工作内存之后,被CPU调度离开了操作系统内核。    

        调度离开之后,其他线程(thread1),有可能对于原来内存当中的值(val),也进行了一次修改(变为val1),然后又改回了(val)。这个时候,线程thread再次读取ovalue的值,读取到的值虽然是和线程工作内存当中的值:value一样。但是,已经有其他线程对于原来的value更改过了。


        总结一下:ABA:

        A:原来线程内存当中的值

        B:其他线程更改过后的值

        A:另外的线程又把B改变回来的值--A


解决ABA问题

对于原来内存当中的val值,同时添加一个版本号来修饰。

初始的时候,版本号为1,当有线程对于这个值进行修改的时候,让版本号+1。

 后续判断&address的expectValue是否相等的操作改变为判断版本号是否相同

 这样,就让A和B的版本号不一样,B和该回去的A的版本号又不一样,也就避免了ABA问题。  

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值