多线程 -- 锁策略/JUC组件/CAS(compare and swap)/线程安全集合类/synchronized工作过程

目录

常见的锁策略

一、乐观锁和悲观锁

二、轻量级锁和重量级锁

三、自旋锁和挂起等待锁

四、互斥锁和读写锁

五、可重入锁和不可重入锁

关于死锁(面试考)

六、公平锁和非公平锁

CAS(compare and swap)

CAS的作用

1.实现原子类

2.实现自旋锁

CAS的ABA问题(面试常考)

synchronized工作过程

偏向锁

锁消除

锁粗化

JUC中的常见组件

1.callable

2.ReentrantLock(可重入的)

3.信号量semaphore

4.CountDownLatch

线程安全集合类

HashTable和ConcurrentHashMap的区别(面试题)

(1)HashTable

(2)ConcurrentHashMap


常见的锁策略

一、乐观锁和悲观锁

锁的实现者预测接下来锁冲突(锁竞争)的概率是大还是不大,根据冲突的概率,决定接下来该怎么做。

乐观锁预测接下来的冲突概率不大

悲观锁预测接下来的冲突概率比较大。

两者因为预测的冲突概率不同,导致最终做的工作就不一样。

悲观锁一般要做的工作更多一些,效率就会更低,一般来说乐观锁做的工作会少一些,效率更高。

二、轻量级锁和重量级锁

轻量级锁加锁解锁,过程更快更高效

1.少量的用户态和内核态切换;2.很容易引发线程的调度

重量级锁解锁解锁,过程更慢,更低效。

1.大量的内核态用户态切换;2.很容易引发新线程调度

与乐观锁和悲观锁有一定的重合,

一个乐观锁可能是一个轻量级锁(不绝对)

一个悲观锁可能也是一个重量级锁(不绝对)

对于轻量级锁,锁冲突的概率比较大,就会转成重量级锁。轻量级锁(自旋锁)吃的cpu比较多,重量级锁会阻塞等待,不吃cpu但是会花费调度时间。

三、自旋锁和挂起等待锁

自旋锁是轻量级锁的一种典型实现;

挂起等待锁是重量级锁的一种典型实现。

情景:和女神表白,被发好人卡,加锁失败,

自旋锁:加锁失败之后,每天仍然锲而不舍,像女神问候,等女神和男朋友分手,机会来了,就加锁。

优点:纯用户态,不涉及内核态。一旦锁被释放,就能第一时间拿到锁,速度会很快。

缺点:如果锁被其他线程持有的时间比较久,那么就会持续消耗CPU的资源(挂起等待锁不会)

挂起等待锁:先去做别的事不管女神,等女神分手,然后主动来找自己,再加锁。

缺点:涉及内核态,等待锁的时间更长

关于synchronized,既是悲观锁也是乐观锁,即使轻量级锁,也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待所实现。

synchronized会根据当前锁竞争的激烈程度,自适应锁策略。

如果锁冲突不激烈,以轻量级锁/乐观锁的状态运行

如果锁冲突激烈,以重量级锁/悲观锁的状态运行。

四、互斥锁和读写锁

synchronized是互斥锁:只加锁,进入代码块加锁,出了代码块,解锁。

读写锁:能够把读和写区分开,1.给读加锁;2.给写加锁;3.解锁

读写锁中,约定:

1.读锁和读锁之间不会有锁竞争,不会产生阻塞等待(不会影响程序的速度,代码还是跑的很快)

2.写锁和写锁之间,有锁竞争(减慢速度,但是保证准确性)

3.读锁和写锁之间,也有锁竞争(减慢速度,但是保证准确性)

读写锁更适用于一写多读的情况。

五、可重入锁和不可重入锁

如果一个锁,在一个线程中,连续对该锁加锁两次,不死锁,就叫可重入锁,如果死锁了,就叫不可重入锁。

比如下面的代码,第二次尝试给locker加锁,就需要等待第一个锁释放,第一个锁释放,就需要等待第二个锁加锁成功(也就是相当于一种情况,车钥匙掉家里了,家里的钥匙掉在扯里了),从逻辑上是矛盾的,此时也就是死锁了,所以是一个不可重入锁。

关于死锁(面试考)

死锁就是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。也就是没有人来解锁。

当多个线程同时被阻塞,他们中的一个或者全部都在等待某个资源被释放。由于线程被巫启贤的阻塞,因此程序就不可能正常终止。

“哲学家吃面条问题”就会产生死锁问题:

有几个哲学家,围着中间有一碗面条的桌子,每个哲学家两两之间放着一根筷子

 假如有以上这六个哲学家,那么他们想要吃到面条,就必须拿到筷子(先拿到左边再拿到右边),此时可以看见,不是每个哲学家都能拿到筷子,当哲学家发现筷子拿不起来了(被别人占用了),就会阻塞等待。

但是此时出现以下情况:

 每个哲学家都拿起自己左手边的筷子,然后尝试拿起右手边的筷子,就会发现右手边的筷子被占用了,由于哲学家们互不相让,就会导致死锁。

关于会产生死锁的情况:

1.一个线程,一把锁,可重入锁没事,不可重入锁死锁;

2.两个线程两把锁,即使是可重入锁,也会死锁;

当两个对象都有对应的锁,然后这两个对象尝试获取对方的锁,此时就会发生死锁,比如以下代码:

对于t1和t2,t1先对locker1加锁,t2先对locker2加锁,

然后后面t1和t2分别对对方的锁加锁(t1对locker2加锁,t2对locker2加锁),此时就会发生死锁。

3.N个线程,M把锁

线程数量和锁数量更多了,就更容易死锁,哲学家就餐问题就是因为这个原因产生死锁。

关于死锁的四个必要条件:

1.互斥使用,一个线程拿到一把锁之后,另一个线程不能使用;

2.不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能被资源占有者主动释放;

3.请求和保持,“吃着碗里,看着锅里”,当资源请求者在请求其他的资源时同时保持对原有资源的占有;

4.循环等待,逻辑依赖循环,“钥匙锁车里,车钥匙锁家里”,就是存在一个等待队列:P1占有P2的,P2占有P3的,P3占有P1的......相互依赖形成一个死循环。

上述的四个必要条件,只要有一个被解决就不会出现死锁。

避免出现死锁:破解循环等待,针对锁进行编号,如果需要同时获取多把锁,无比是先对编号小的加锁,然后对编号大的加锁。

比如前面出现的情况:

 想要解决这个问题,可以将对象调换:

 约定好先获取lock1,再获取lock2,就不会环路等待。

六、公平锁和非公平锁

假如有A,B,C,

公平锁:遵守“先来后到”,B比C先来的,当A释放锁之后,A就能线于C获取锁

非公平锁:不遵守“先来后到”,B和C都有可能获取到锁

synchronized默认情况下是一个非公平锁。

关于synchronized,既是悲观锁也是乐观锁,即是轻量级锁,也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待所实现,不是读写锁,是可重入锁,是非公平锁。

CAS(compare and swap)

CAS就是一条指令,主要作用如下:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

1. 比较 A 与 V 是否相等(比较)

2. 如果比较相等,将 B 写入 V(交换)

3. 返回操作是否成功

也就是比较内存A和寄存器V中的值,如果数值相同,就把内存B和寄存器V中的值进行交换。

更多时候,我们不关心寄存器中的值,更关心内存的数值,这种操作相当于赋值操作。

对应的伪代码如下:

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

对于上述伪代码,不是原子操作,可能会引起线程安全问题,但是CAS是一个单条的指令,是符合原子性的,不存在线程安全问题。

CAS的作用

1.实现原子类

标准库中提供了java.util.concurrent.atomic包,里面的类都基于这种方式来实现的,

比如AtominclInteger类,就是一个原子类,其中的getAndIncrement就相当于i++操作:

AtomicInteger num = new AtomicInteger();
num.getAndIncrement();

伪代码实现:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

2.实现自旋锁

基于CAS实现更灵活的锁,获得到更多的控制权。

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 

        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 

        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 

        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

CAS的ABA问题(面试常考)

CAS关键是对比内存和寄存器的值是否相同(就是通过这个对比,来检测内存是不是变过)

万一对比的时候是相同的,但是不是没变过,而是从a -> b -> a(值是相同的,但是中间可能变过,不是原来的a了),此时,有一定概率就会出问题(类比翻新机)

CAS只能对比值是否相同,不能确定这个值是否中线发生过改变,大概率上是不会出现问题的。

如果约定数据只能单方向变化,就能解决此问题:

如果需求要求该数值,既能增加也能减小,可以引入另一个版本号变量,约定版本号只能增加,这样每次CAS对比的时候,就不是对比数值本身,而是对比版本号,每次修改都会增加一个版本号,如果版本号相同则没有修改,如果相同,则是修改了的。

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. CAS 操作在读取旧值的同时, 也要读取版本号.

真正修改的时候:

        如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.

        如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

synchronized工作过程

 synchronized默认乐观锁,如果锁冲突频繁,就会转换为悲观锁

默认时轻量级锁实现,如果锁被持有的时间较长就会转换为重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待所实现,实现轻量级锁的时候,大概率会使用自旋锁策略。

默认是一种不公平锁,是一种可重入锁,不是读写锁。

偏向锁

让线程针对锁有一个标记(做标记很轻量),此时还没加锁,如果整个代码执行过程中,都没遇到别的线程和我竞争整个锁,就不真的加锁,但是如果有别的线程尝试来竞争这个锁,此时就立即对这个锁进行加锁(升级为真的锁,轻量级锁),此时别的线程就只能等待。

既保证了效率,也保证了线程安全。

偏向锁是synchronized内部做的工作,它会针对某个对象进行加锁,“偏向锁”只是给这个对象做个标记,如果另一个线程也尝试对同一个对象加锁,也要现场时做标记,但是此时发现标记已经有了,于是JVM就会通知到先来的线程,把锁进行升级(加锁)。

锁消除

非必要不加锁。

锁消除是编译阶段的做的优化手段,检测当前代码是否是多线程执行,如果无必要,就会在编译阶段中自动把锁去掉。

锁粗化

锁的粒度,synchronized代码块,包含代码的多少(代码越多,粒度越粗,越少,粒度越细)

一般情况下希望锁的粒度更小 (串行执行的代码少,并发执行的代码就越多。)

如果某个场景,要频繁加锁解锁,此时编译器就可能把这个操作优化成一个更粗粒度的锁,

比如以下有一个例子:

上班之后,给领导汇报ABC三个工作情况,此时,有以下几种汇报方式:

第一种:

先打个电话,汇报工作A进展,挂了电话;

再打个电话,汇报工作B进展,挂了电话;

再打个电话,汇报工作C进展,挂了电话。

第二种

打一个电话,汇报工作A,工作B,工作C的进展,再挂电话

上述打电话挂电话,就相当于频繁的加锁解锁,每次加锁解锁都会有开销,特别是释放锁以后,重新加锁,还需要锁竞争,基于这个原因,我们进行锁粗化(打电话汇报完了再挂电话),编译器就将这个操作优化成一个更加粗粒度的锁。

JUC中的常见组件

JUC(java.util.concurrent)

1.callable

Callable的用法,类似于Runnable,描述了一个任务(一个线程的具体实现方式),Runnable通过run方法描述,返回类型void,很多时候,希望这些任务有返回值,有一个具体值产出,此时就用到Callable里面的call方法。

public static void main(String[] args) {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                return null;
            }
        };
    }

此时泛型参数写的什么,call方法的返回值类型就是什么。

 public static void main(String[] args) {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i < 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
​
        //添加线程,完成该任务
        //Thread不能直接传callable,需要再包一层
        //FutureTask相当于取餐小票,上面写了后面取到的任务
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
     //获取上述任务call方法返回值的结果
     //get方法相当于join,如果线程没执行完,会阻塞等待
     System.out.println(futureTask.get());

实现线程的方法:1.继承Thread; 2.实现Runnable;3.基于lambda;实现callable

2.ReentrantLock(可重入的)

与synchronized关键字不同,synchronized是基于代码块的方式来控制加锁和解锁的,

而ReentrantLock是提供了lock和unlock独立的方法,来进行加锁和解锁。

1.synchronized只是加锁解锁,加锁的时候如果发现锁被占用,只能阻塞等待,

而ReentrantLock还提供了一个tryLock方法,如果加锁失败,不会阻塞等待,直接返回false;

2.synchronized是一个非公平锁(概率均等,不遵守先来后到),

ReentrantLock提供了公平和非公平两种工作模式(在构造方法中,传入true即可开启公平锁)

3.synchronized搭配wait/notify进行等待唤醒,如果多个线程wait是同一个对象,notify的时候是随机唤醒一个,

ReentrantLock则是搭配Condition这个类,这个类也能起到等待通知,可以功能更强大(比如指定那个对象唤醒)。

3.信号量semaphore

由迪杰斯特拉(图的最短路径)提出

本质上是一个计数器,描述了当前“可用资源” 的个数

P操作,申请资源,计数器-1 申请:accqurie

V操作,释放资源,计数器+1 释放:release

如果计数器已经是0,继续申请资源,就会阻塞等待。

对于锁,本质上是一个计数器为1的信号量,取值只有1和0两种,也叫做二元信号量。

信号量是更广义的锁,不仅可以管理非0即1的资源,也能管理更多资源。

4.CountDownLatch

比如主线程,弄十个线程,主线程创建一个CountDownLatch对象,构造方法写10

是个线程分别执行各自的下载操作,主线程使用CountDownLatch.await(await方法是为了计算有几个countDown被调用了)当打,来阻塞等待所有任务完成,是个线程每个线程执行完,都调用一个CountDownLatch.countDown方法(选手到达终点),当十个线程都调用过了一户,此时主线程的await就阻塞解除了,接下来就可以进行后续工作了。

线程安全集合类

常用的,Arraylist,LinkedList,HashMap,PriorityQueue......大都是线程不安全的

1.把修改操作加锁,手动保证

2.标准库提供了一些线程安全版本的集合类

比如CopyOnWriteArrayList支持“写时拷贝”集合类

写时拷贝就是修改数据的时候就拷贝一份,相当于引用赋值操作,这样的操作就是原子的,可以保证线程安全,不用加锁也能够完成修改。

虽然不加锁,但是拷贝开销比较大,使用于不频繁修改的操作。

HashTable和ConcurrentHashMap的区别(面试题)

(1)HashTable

加锁粒度的不同,出发所冲突的频率也就不同。

HashTable是针对整个哈希表加锁,任何的增删改查操作都会出发锁,也就都会可能有锁竞争。

插入元素:根据key计算hash值 -> 数组下标

把或者新的元素给挂到对应下标的链表上(java HashMap还会在链表太长的时候,把链表变成红黑树)

如上图,线程1插入的元素,对应在下标为1的链表上,线程2插入的元素,对应在下标为2的链表上,由于是两个线程修改不同的变量,就没有线程安全问题,但是由于synchronized是加到this上,仍然会针对同一个对象进行锁竞争,就会产生阻塞等待,这样的操作很多余,没有必要,于是就引入了ConcurrentHashMap。

(2)ConcurrentHashMap

ConcurrentHashMap就不是只有一把锁,而是将每个链表的头节点作为一把锁,每次进行操作,都是针对对应链表的锁进行加锁,操作不同的链表就是针对不同德锁加锁,不会有所冲突,这样就会使大部分加锁操作实际上是没有锁冲突德,此时这里的加锁操作的开销就会很小了。

在java1.7及其之前,ConcurrentHashMap使用的是“分段锁”,目的和上述差不多,但是是几个链表公用同一把锁。

除了上面的核心区别,还有:

(1)更充分的利用了CAS机制(无锁编程),能保证线程安全,比锁更高效

(2)优化了扩容策略,对于HashTable,如果元素过多,就会涉及到扩容(扩容需要重新申请内存空间,搬运元素,就是把元素从旧的哈希表上删除,插入到新的哈希表上)

此时,搬运一次的成本就会很高,可能某一次put操作就会导致卡顿,

但是ConcurrentHashMap策略,可以化整为零,并不会试图一次性就把所有元素搬运过去,而是每次搬运一部分,当put触发到扩容,就会直接创建更大的内存空间,但是不会直接把所有元素都搬运过去,而是只搬运了一小部分,此时相当于同时存在两份hash表,此时插入元素,直接往新表插入;删除元素,删旧表或者新表的元素;查找则新表旧表都查,并且每次操作过程中,都会搬运一部分过去ConcurrentHashMap很详细可以看看

这个讲的https://blog.csdn.net/u010723709/article/details/48007881

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值