【多线程】-锁策略以及线程安全

本篇学习常见的锁策略以及进阶的一些关于多线程的安全知识


常见的锁策略

对于锁的问题,解决问题的方法 。
锁的规则只有一个:两个线程对同一个对象加锁,才会锁冲突(阻塞等待)

乐观锁和悲观锁

锁的实现者,预测接下来锁冲突(就是锁竞争)的概率是大,还是不大,根据这个冲突的概率,来决定接下来应该怎么做。
乐观锁:预测接下来冲突概率不大
悲观锁:预测接下来冲突概率比较大
通常来说:悲观锁一般做的工作要更多一些,效率更低一些
乐观锁做的工作要更少一点,效率更高一点(但是并不绝对)

轻量级锁和重量级锁

轻量级锁:加锁解锁,这个过程更快,更高效
重量级锁:加锁解锁,这个过程更慢,更低效

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

自旋锁和挂起等待锁

自旋锁是轻量级锁的一种典型实现
一旦锁被释放第一时间拿到锁–速度更快----但会忙等,消耗cpu资源
通常是纯用户态,不需要经过内核态(时间相对较短)

挂起等待锁是重量级锁的一种典型实现
如果锁被释放,不能第一时间拿到锁~~速度慢,但锁会被释放
通过内核的挂起机制,来实现挂起等待(时间更长了)

synchronized 既是悲观锁,也是乐观锁,既是轻量级锁,也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。synchronized会根据当前锁竞争的激烈程度,自适应!!
如果锁冲突激烈,以轻量级锁/乐观锁的状态运行——实现挂起等待锁比较合适
如果锁冲突不激烈,以重量级锁/悲观锁的状态运行——实现自旋锁比较合适

互斥锁和读写锁

aychronized 就是互斥锁~~只有两个操作:1.进入代码块,加锁。2.出代码块解锁
加锁,加锁简单的加锁,没有更细化的区分
除此之外,还有一种读写锁,能够把读和写两种锁区分开来
读写锁:1.给读加锁。2.给写加锁。3.解锁。
读写锁中,约定:

  1. 读锁和读锁之间,没有锁竞争,也不是产生阻塞等待。(不会影响代码的速度,代码运行还很快)
  2. 写锁和写锁之间,有锁竞争(减慢速度,但是保证准确性)
  3. 读锁和写锁之间,也有锁竞争(减慢速度,但是保证准确性)

读写锁更适合,一写多读的情况

Java标准库提供了两个专门的读写锁
(读锁是 一个类,写锁是一个类)

可重入锁和不可重入锁

如果一个锁在一个线程中,对一个锁进行连续加锁两次,不死锁,就是可重入锁。如果,死锁就是不可重入锁

synchronized是一个可重入锁,加锁的时候会判定一下,看当前尝试申请锁的线程是不是已经是锁的拥有者了!!!如果是直接放行

关于死锁:

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
什么情况下造成死锁:

  1. 一个线程,一把锁(上面的情况),可重入锁没事,不可重入锁死锁
  2. 两个线程两把锁,即使是可重入锁也会死锁
  3. N个线程,M把锁,线程数量和锁数量更多了,就更容易死锁了

死锁的四个必要条件:

  1. 互斥使用。一个线程拿到一把锁后,另一个线程不能使用。(锁的基本特点)
  2. 不可抢占。一个线程拿到锁,只能主动释放,不能被其他线程强行占有
  3. 请求和保持。
  4. 循环等待,有逻辑依赖循环

以上条件缺一不可

如何避免死锁:

对锁进行编号,如果需要同时获取多把锁,约定加锁顺序,务必是先对先对小的编号加锁,
后对大的编号加锁,只要约定等待顺序,循环等待自然破除,死锁不会形成

公平锁和非公平锁

此处,约定,遵守先来后到,加就是公平锁,不遵守先来后到就是非公平锁!!!(等概率竞争,是不公平的)

系统对线程的调度是随机的,自带的synchronized这个锁是非公平的。
要想实现公平锁需要在sychronized的基础上,加给个队列,来记录这些加锁线程的顺序

sychronized特点

  1. 既是乐观锁,,也是悲观锁
  2. 既是轻量级锁,也是重量级锁
  3. 轻量级锁基于自旋实现,重量级锁基于挂起等待实现
  4. 不是读写锁
  5. 是可重入锁
  6. 是非公平锁

CAS

compare and swap :比较寄存器A的值和内存M的数据值,如果数值相同,就把寄存器B和M的数值进行交换。
更多的时候,不关心寄存器的数值是啥,更关心内存中的数值(就是变量中的值)

CAS操作,是一条CPU指令,并非是上述一段代码,是靠硬件CPU的支持,本身就是原子操作
基于CAS就可以实现很多操作

1.基于原子类
标准库里提供Atomlnteqer类:保证1了++,–的时候的线程安全
代码示例:

public class ThreadDemo26 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);

        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //相当于num++
                num.getAndIncrement();
                /*//相当于++num
                num.incrementAndGet();
                //相当于--num
                num.decrementAndGet();
                //相当于num--
                num.getAndDecrement();*/

            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        //使用get获取数值
        System.out.println(num.get());
    }
}

2.实现自旋锁

反复检查当前锁状态,是否解开了。

CAS的aba问题

CAS的关键就是对比内存和寄存器的值,是否相同(就是通过这个对比,来检测内存是不是改变过)
但是不能确定这个值是否在中间发生过改变
解决办法为:约定数据只能单方面变化(只能增加或者减少)
如果需求要求该数值既能增加也能减少,可以引入另一个版本号变量,约定只能增加(每次修改,都会增加一个版本号)
每次CAS的时候 ,基本上对比数值本身,而是对比版本号

synchronized 工作过程

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

sychronized的关键策略:锁升级
加锁的升级过程:无锁—偏向锁(刚开始加锁,是偏向锁状态)—自旋锁(遇到锁竞争,就是自旋锁【轻量级锁】)—重量级锁(锁竞争激烈,就会变成重量级锁【交给内核阻塞等待】)意味着线程暂时放弃CPU,有内核进行后续调度

偏向锁:非必要,不加锁
偏向锁,只是先让线程针对锁,有个标记(做个标记很快,非常轻量),如果在代码执行过程中,没有遇到别的线程竞争锁,此时就不会加锁了,‘一旦,有别的线程尝试竞争这个锁!这个偏向锁就立即升级成真的锁(轻量级锁),此时别的线程只能阻塞等待
这样既保证效率,又保证了线程安全

锁消除:非必要不加锁
编译过程中做的优化手段,检测当前代码是否多线程执行/是否有必要加锁,如果没有必要,又把锁给写了,将会在编译过程中自动把锁去掉!!!

锁粗化
锁的粒度,sychronized代码块,包含代码的多少(代码越多,粒度越粗,越少,粒度越小)
一般写代码的时候,多数情况下,是希望粒度更小一些。(串行执行的代码少,并发执行的代码就多)
如果某个场景,要频繁的加锁/解锁,此时编译器就可能把这个操作优化成一个更粗粒的锁

JUC的常见组件

什么是JUC

JUC(Java Util Concurrent)是Java SE 5中增加的一个包,用于支持多线程并发编程。这个包提供了比传统的java.lang.Thread和Object.wait()、notify()等原语更高层次的并发编程抽象,以及更为丰富和易用的线程池、阻塞队列、原子变量、锁、同步器、并发集合等工具类。
JUC包中的主要类和接口包括:

  1. ConcurrentHashMap:基于分段锁实现的线程安全的哈希表。

  2. BlockingQueue:阻塞队列,提供了线程安全的队列操作,可用于实现生产者-消费者模式。

  3. ThreadPoolExecutor:线程池,实现了可配置的线程池,包括线程数量、拒绝策略、任务队列等参数。

  4. Semaphore:信号量,可用于控制并发访问的数量限制。

  5. CountDownLatch:倒计时门栓,可以使得等待某些线程执行完毕后才能继续执行其他线程。

  6. ReentrantLock:可重入锁,与synchronized关键字类似,但提供了更为灵活的锁特性,如可中断、超时等。

  7. AtomicXXX:原子变量,提供了基本数据类型的原子操作,避免了多线程操作时的竞争条件问题。

  8. CyclicBarrier:循环屏障,可以控制多个线程在同步点处阻塞,直到所有线程都到达同步点后才继续执行。

Callable 接口
Callable 的用法
Runnable类似:通过run方法描述,返回值类型void
Callable :call,有返回值

ReentrantLock

可重入的
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 的用法:
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁
虽然大部分情况下,使用synchronized就足够了,但是ReentrantLock有一个重要的补充
三个方面:

  1. synchronized只能加锁解锁,加锁的时候如果发现锁被占用,只能阻塞等待,但是ReentrantLock提供一个tryLock方法,如果加锁成功,没有区别,如果失败,不会阻塞,直接返回false!!更加灵活
  2. synchronized是一个非公平锁(概率相等,抢占式执行),ReentrantLock提供了公平和非公平两种工作模式(在构造方法中,传入true开启公平锁)
  3. ReentrantLock搭配wait和notify进行等待唤醒,如果多个线程wait一个对象,notify的时候是随机唤醒一个,ReentrantLock则是搭配Condition这个类,这个类也能起到等待通知,可以功能更强大

原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference

以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i–;
incrementAndGet(); ++i;
getAndIncrement(); i++;

信号量 Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
有两个操作:
P操作,申请资源,计数器-1
V操作,释放资源,计数器+1
所谓锁本质上就是计数器为1的信号量,取值只是1和0两种,也叫二元信号量
而此处的信号量,是更广义的锁,不光能管理非0即1的资源,也能管理多个资源

CountDownLatch

同时等待 N 个任务执行结束.
这个类,可以感知最后一个线程执行结束
构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.

public class Demo {
    public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(10);
        Runnable r = new Runable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(Math.random() * 10000);
                    latch.countDown();
               } catch (Exception e) {
                    e.printStackTrace();
               }
           }
       };
        for (int i = 0; i < 10; i++) {
            new Thread(r).start();
       }
   // 必须等到 10 人全部回来
        latch.await();
        System.out.println("比赛结束");
    }
}

线程安全的集合类

多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)

  2. Collections.synchronizedList(new ArrayList);
    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
    synchronizedList 的关键操作上都带有 synchronized

  3. 使用 CopyOnWriteArrayList
    CopyOnWrite容器即写时复制的容器。支持“写时拷贝”
    当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
    添加完元素之后,再将原容器的引用指向新的容器。
    这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
    所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
    优点:
    在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点:

  1. 占用内存较多
  2. 新写的数据不能被第一时间读取到

如果是多线程读,由于读本身就是线程安全,没事,如果此时有一个线程在尝试修改,就会触发“写时拷贝”。(系统会先进行一次拷贝,然后再进行修改,从而保证了线程安全性和数据一致性。)

在修改时,创建另一个容器进行修改,然后指向新的容器

多线程使用队列,使用BlockingQueue

  1. ArrayBlockingQueue
    基于数组实现的阻塞队列

  2. LinkedBlockingQueue
    基于链表实现的阻塞队列

  3. PriorityBlockingQueue
    基于堆实现的带优先级的阻塞队列

  4. TransferQueue
    最多只包含一个元素的阻塞队列

多线程环境使用哈希表

HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:Hashtable;ConcurrentHashMap

Hashtable

只是简单的把关键方法加上了 synchronized 关键字。

这相当于直接针对 Hashtable 对象本身加锁.
如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低

ConcurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例

  1. 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用 synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率。
  2. 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
  3. 优化了扩容方式: 化整为零
    发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
    扩容期间, 新老数组同时存在.
    后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
    搬完最后一个元素再把老数组删掉.
    这个期间, 插入只往新数组加.
    这个期间, 查找需要同时查新数组和老数组

Hashtable和ConcurrentHashMap的区别:

  1. 加锁粒度的不同,触发锁冲突的频率(最大,最 关键,最核心的区别)
    HashTable是针对整个哈希表加锁,任何增删改查,都会触发加锁,也就有可能有锁竞争
    ConcurrentHashMap是给哈希表的每个链表(头节点)进行加锁,每次进行操作,都是针对对应链表的锁进行加锁,操作不同链表就是针对不同的锁加锁,不会有锁冲突,这样大部分加锁操作实际上没有锁冲突,此时加锁操作的开销就是微乎其微了。

  2. ConcurrentHashMap更充分的利用了CAS机制~~无锁编程
    有的操作,比如获取元素个数/更新元素个数,接就可以直接使用CAS完成,不必加锁了

  3. 优化了扩容策略
    ConcurrentHashMap对于HashTable
    扩容需要申请内存操作,搬运元素(把旧的哈希表删除,插入到新的哈希表上)
    ConcurrentHashMap策略,化整为零~并不会试图一次性把所有元素都搬运过去
    当put触发扩容,此时就会直接创建更大的内存空间,并不会直接把所有元素搬运过去,而是搬运一小部分(速度较快)
    此时,就存在了两份hash表,此时插入元素,直接往新表插,删除元素,删除旧表的,查找,新表旧表都查,并且每次操作过程中,都会搬运一部分过去

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值