当前位置: 首页 > news >正文

【JavaEE】(3) 多线程2

一、常见的锁策略

1、乐观锁和悲观锁

  • 悲观锁:预测锁冲突的概率较高。在锁中加阻塞操作。
  • 乐观锁:预测锁冲突的概率较低。使用忙等/版本号等,不产生阻塞。

2、轻量级锁和重量级锁

  • 重量级锁:加锁的开销较大,线程等待锁的时间更长。
  • 轻量级锁:加锁的开销较小。线程等待锁的时间更短。

3、挂起等待锁和自旋锁

  • 挂起等待锁:是悲观锁/重量级锁的典型实现。遇到锁冲突,让线程挂起等待,调度出 cpu,产生阻塞。
  • 自旋锁:是乐观锁/悲观锁的典型实现。遇到锁冲突不放弃 cpu,而是“忙等”,没有产生阻塞,不停地尝试拿锁。

        sychronized 属于自适应的,当竞争不激烈时,采取自旋锁策略;竞争激烈时,采取挂起等待锁策略。

4、公平锁和非公平锁

当锁被释放后,让哪个线程获取锁的策略。

  • 公平锁:先来后到。需要引入队列记录顺序。
  • 非公平锁:概率平等

5、可重入锁和不可重入锁

  • 可重入锁:同一把锁连续加锁没有死锁。如 sychronized。
  • 不可重入锁:同一把锁连续加锁,死锁。

6、读写锁和互斥锁

  • 互斥锁:如 sychronied,加锁、解锁。
  • 读写锁:加读锁、加写锁、解锁。某些场景读操作比写操作多得多,读写锁让读锁与读锁间不产生互斥(多线程读没有线程安全问题),读锁与写锁之间写锁与写锁之间产生互斥

二、sychronized 的优化

1、锁升级

        synchronized 对锁策略的自适应调整,其实是锁升级的过程(只升级,不降级):

  • 偏向锁一开始没有竞争时,是偏向锁,只是修改一个标记来代替加锁,当出现竞争时,才真正加锁(类似懒汉模式)。
  • 自旋锁:当出现锁竞争时,升级为自旋锁,采用忙等的方式解决锁冲突,能第一时间拿到锁。
  • 重量级锁:当竞争激烈时,升级为重量级锁,采用阻塞的方式解决锁冲突。

        而竞争的激烈程度,是由 JVM 内部统计这个锁上有多少个线程在等待获取。

2、锁消除

        有时在代码中写了加锁但并不必要,JVM 就会自动把锁给去掉。比如 StringBuilder 不带 synchronized,StringBuffer 带 synchronized,在单线程中使用 StringBuffer 就是没有必要的。

3、锁粗化

        锁的粒度就是在加锁解锁的范围内代码越多锁的粒度越粗;反之越细。但加锁解锁会影响效率,锁粒度越细,加锁解锁就越频繁,有时是没有必要这么细,JVM 就会进行锁粗化

三、CAS(Compare and Swap)

1、什么是 CAS

        CAS 的伪代码:

  • addtress 是内存地址,expectValue、swapValue 是两个寄存器存放的值。
  •  内存的值跟寄存器1的值相等,就将内存的值跟寄存器2的值交换(因为我们只关心内存的值,所以直接将寄存器1的值赋值给内存),并返回 true。
  • 如果不相等就返回 false。
  • 这些逻辑是由一条 CPU 指令完成的,意味着它是原子的

        操作系统封装了这个指令为系统API,Java 又封装了系统API 为 unsafe 包里的操作,比较底层,可能不安全。因此我们常用的不是 unsafe,而是 unsafe 里的操作的进一步的封装类,比如原子类,等。

2、CAS 实现原子类

        常用的就是这几个原子类:

        原子类里的各种操作都是原子的,如下对原子整形类的各种操作:

import java.util.concurrent.atomic.AtomicInteger;public class Demo1 {private static AtomicInteger counter = new AtomicInteger(0);public static void main(String[] args) {// 以下的操作都是原子的,不加锁也能线程安全counter.incrementAndGet(); // 自增并返回新值,相当于 ++countercounter.decrementAndGet(); // 自减并返回新值,相当于 --countercounter.getAndIncrement(); // 返回当前值并自增,相当于 counter++counter.getAndDecrement(); // 返回当前值并自减,相当于 counter--counter.addAndGet(5); // 加上给定值并返回新值,相当于 counter += 5counter.getAndAdd(5); // 返回当前值并加上给定值,相当于 counter += 5counter.getAndSet(10); // 返回当前值并设置为新值,相当于 counter = 10}
}

       可以看到底层是用 CAS 实现的:

        getAndIncrement 伪代码:

class AtomicInteger {private int value;public int getAndIncrement() {int oldValue = value;while ( CAS(value, oldValue, oldValue+1) != true) {oldValue = value;}return oldValue;  // 先返回旧值,再自增的逻辑}
}
  • 判断内存中的 value 是否跟寄存器1 中的 oldValue 一样,一样则更新 value 内存值为 oldValue+1,并返回 true,跳出循环返回自增后的值(一样则说明,没有其它线程更改过 value 内存的值,可以进行安全的更新操作)。
  • 不一样,则返回 false,load 内存中的值 value 到寄存器1 oldValue,继续判断(不一样说明,其它线程更改过内存 value 的值,需要更新寄存器1中 oldValue)。
  • CAS 实现的操作,解决了多线程对同一变量修改的线程不安全问题;也解决了内存可见性问题。

        使用原子类的示例,两个线程同时对 count 计数,没有出现线程不安全问题:

        计数的场景推荐使用 CAS,因为它不用加锁解锁效率高。比如统计服务器一天有多少用户访问量、有多少个广告被展示等。

3、CAS 实现自旋锁

  • 当 owner 不为空,说明锁被其它线程占有,那么就一直循环等待,也一直占用 cpu 资源,在竞争不激烈的时候,当锁被释放,能第一时间拿到锁,这就是忙等。 
  • 当 owner 为空,就将当前线程的引用赋值给 owner,表示加锁。
  • 解锁,就让 owner 重新为 null(单纯的赋值,本身就是原子操作)。
  • 当竞争激烈时,不要用自旋锁,因为有大量线程处于忙等的状态,占用大量 cpu 资源。

4、CAS 的 ABA 问题

        虽然 CAS 是原子的,也能避免内存不可见问题,但是当把 A 改为 B 又改为 A 时,因为值 A 没变,会误判为没有进行修改。如下面的银行取钱场景:按了一个取钱,产生线程 t1,这时atm卡住了;又按了一次取钱,产生线程 t2,扣了 500;这时又有 t3 线程转 500 回来;最后卡住的 t1 又恢复,因为余额没变(1000),所以又多扣了一次 500。

        解决办法,引入版本号AtomicStampedReference<V> 类不仅包装了 value 属性,还包装了版本号属性每进行一次修改版本号就会加1。在比较的时候,虽然 value 又改回了 1000,没有变,但是版本号增加了两次,因此版本号不同,不触发多余的扣款。

四、JUC(java.util.concurrent)的常见类

        concurrent 就表示并发,java 中的并发以多线程体现,所以这个包里都是关于多线程的一些类。

1、Callable 接口

        作用类似于 Runnable,用于描述一个任务,但是他多了一个返回值。Runnable 关注一段逻辑,Callable 关注一段逻辑运行的结果。

        如果 Runnable 想要得到一个任务执行的结果,需要在类里加一个属性,用于在 Runnable 中存储结果,但是这样类的成员属性和 Runnable 任务的耦合就比较高,我们不希望高耦合

        而 Callable 会返回执行结果,Callable 是泛型类,可以设置返回值的类型;将 Callable 传给 Thread 执行,需要用 FutureTask 进行包装;使用 Future 类的 get 方法获取结果,当逻辑没执行完时,get 方法阻塞,所以会抛出 InterruptedException,另外还有 ExecutionException 异常。

import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class Demo3 {public static void main(String[] args) throws InterruptedException, ExecutionException {FutureTask<Integer> futureTask = new FutureTask<>(() -> {int result = 0;for(int i = 1; i <= 100; i++) {result += i;}return result;});Thread thread = new Thread(futureTask);thread.start();System.out.println(futureTask.get());}
}

总结,创建线程的方式

  • 继承 Thread。
  • 实现 Runnable。
  • 实现 Callable。
  • 线程池。

2、ReentrantLock

        ReentrantLock 也是可重入互斥锁,但它与 synchronized 不同的是:

  • synchronized 是 JVM 内部实现的,reentrantLock 是 JVM 外部实现的(Java)。
  • reentrantLock 需要手动加锁、解锁,所以容易遗漏解锁;可以用 try-finally 让程序无论以何种方式结束都能执行解锁,但这种写法不太优雅,这是它相对于 sychronized 的缺点。

  • synchronized 加锁失败会死等,而 reentrantLock 加锁失败可以直接放弃等待,或者等一段时间放弃(使用 tryLock 实现)。

        或者多次尝试加锁失败后放弃:

  • synchronized 是非公平锁;reentrantLock 可以是非公平的,也可以设置为公平的(传入 true 参数)。

  • synchronized 通过 wait/notify 实现等待-唤醒,随即唤醒一个等待线程;reentrantLock 通过 Condition 类实现等待-唤醒,可以精准唤醒某个等待线程。

3、信号量 Semaphore

        信号量本质是一个可用资源数计数器P 操作申请资源,计数器减1V 操作释放资源,计数器加1。当计数器为 0 时,表示没有可用资源,这时申请资源就会发生阻塞(因此也会抛出 InterruptedException 异常)。

        可用资源数为 1 的信号量,就相当于加锁

总结,编写线程安全的代码,可以用:

  • 加锁
  • CAS(原子类)
  • 信号量

4、CountDownLatch

        可用于计数已完成的线程数,当所有线程都完成后,阻塞结束

import java.util.concurrent.CountDownLatch;public class Demo3 {public static void main(String[] args) throws InterruptedException {// 初始化,计数的任务个数CountDownLatch countDownLatch = new CountDownLatch(8);for (int i = 0; i < 8; i++) {int id = i;Thread thread = new Thread(() -> {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程" + id + "完成任务");// 计数countDownLatch.countDown();});thread.start();}// 所有线程执行结束后,结束阻塞countDownLatch.await();System.out.println("所有任务完成");}
}

        应用场景:把一个大任务拆成多个子任务,比如多线程下载,通过多线程与资源服务器建立多个网络连接。运营商说套餐升级为多大的带宽是没用的,因为资源服务器供应商会限制带宽,就算你有 500 Mbps(62.5 MB/s) 的带宽,一个线程能达到 5 MB/s 都算快的。像百度网盘买了会员,会提供多线程下载,就会快很多。

5、线程安全的集合类

5.1、多线程环境使用 ArryList

  • 使用同步机制。
  • Collections.synchronizedList(new ArrayList):返回的 List.synchronizedList 的关键方法带有 sychronized 关键字。
  • CopyOnWriteArrayList:写时复制容器。对写操作加锁,对读操作不加锁,让读的性能提升,适用于“多读少写”的场景。写操作时,会先复制当前数组的副本,再对副本进行修改,再将当前数组的引用指向副本。读操作时,直接访问当前数组(跟副本不是同一个对象,如果读操作在写之前读取,就算写操作更新了当前数组的引用,读操作访问的也是之前的数组对象)。缺点就是:占内存多、不能第一时间读取到新写的数据。

5.2、多线程环境使用队列

  • ArrayBlockingQueue:基于数组的阻塞队列
  • LinkedBlockingQueue:基于链表的阻塞队列
  • PriorityBlockingQueue:基于堆的优先级阻塞队列
  • TransferQueue:最多只包含一个元素的阻塞队列。

5.3、多线程环境使用 Hash 表

        HashMap 是线程不安全的,比如多个线程对同一个哈希表中的同一个链表进行修改,对存储的键值对数量 size 进行修改。可以用以下方法解决:

  • 自己加锁。(不推荐,肯定没有现成的包好用)
  • Hashtable。(不推荐,他对关键方法用 synchronized 修饰,锁是 this,任何线程对 Hash 表进行修改,都会触发阻塞,导致效率大大降低。我们根本没必要对整个表用同一把锁,因为对不同链表上的数据进行修改是线程安全的。所以 Hashtable 即将被 JDK 废弃)

  • ConcurrentHashMap:

① 它采用锁桶的方案,将数组中存储的链表引用作为锁(每个锁就是一个锁桶),对哈希表上的元素进行操作,大概率分布在不同锁桶上,触发锁竞争的概率很小

② 而 size 的修改是针对整个表的操作,如果依然用锁实现,那么锁桶的优化就没有什么用了。因此对 size 的修改采用的是 CAS 操作。

③ 在扩容时采取 “化整为零” 的方案。因为一般数据量很大,扩容时需要搬运的数据就很多,为了保证线程安全,如果采用锁,那么阻塞的时间就很长很长,导致其它线程无法使用哈希表。采取的方法就是,一旦触发扩容,每次进行 get、put、remove 等都会搬运一点

        ConcurrentHashMap 的使用方法跟 HashMap 大致一样:

http://www.lqws.cn/news/440659.html

相关文章:

  • API网关Apisix介绍
  • MySQL高可用方案解析与选型指南
  • Android图形系统框架解析
  • 【MySQL基础】MySQL内置函数全面解析:提升你的数据库操作效率
  • AI与大数据如何驱动工业品电商平台的智能决策?
  • mongodb单节点改副本集模式
  • Spring Boot + MyBatis + Vue:打造高效全栈应用的黄金组合
  • CppCon 2017 学习:Esoteric Data Structures and Where to Find Them
  • 《汇编语言:基于X86处理器》第2章 复习题
  • infinisynapse 使用清华源有问题的暂时解决方法:换回阿里云源并安装配置PPA
  • flink的多种部署模式
  • YOLOv8改进:Neck篇——2024.1全新MFDS-DETR的HS-FPN特征融合层解析
  • 使用 rsync 拉取文件(从远程服务器同步到本地)
  • Mac 安装ElasticSearch和Kibana详细教程
  • 【面试题002】synchronized和lock的区别
  • C#最佳实践:为何优先使用查询语法而非循环
  • Kafka使用Elasticsearch Service Sink Connector直接传输topic数据到Elasticsearch
  • 清除 docker 无用的 镜像/容器
  • 国产Linux银河麒麟操作系统安装中望CAD和开源社区版QCAD软件
  • python智慧物业管理系统
  • 数据差异的iOS性能调试:设备日志导出和iOS文件管理
  • LVS 负载均衡群集
  • 【目标检测】非极大值抑制(NMS)的原理与实现
  • 拆分合并PDF 图片互转——c#编程实现
  • 怎么把idea的maven项目按照层级显示
  • Python设计模式终极指南:18种模式详解+正反案例对比+框架源码剖析
  • C++ 运算符重载
  • 基于Python的房屋信息可视化及价格预测系统
  • 《Kubernetes》Pod详解+Pod控制器
  • openssl创建证书解决不安全问题