Java基础回顾【20230731】
文章目录
常见问题
1. 对AQS 和ReentrantLock的认识?
AQS 抽象队列同步器
用来构建锁和实现一些同步组件的基础框架,比如ReentrantL ock、ReentrantReadWriteLock、Countdownlantch等;
定义了一些抽象的公共方法,比如定义了获取锁(tryAcquired)、释放锁(tryRelease)的方法,这些方法需要子类去实现特点:
- AQS内部维护了一个CLH阻塞队列,该队列是一个双向链表,并且该链表的头部还存在一个虚拟节点(保存节点状态,唤醒后面节点时使用)
- AQS中定义了资源的共享方式:独占(Exclusive, 只有1个线程能执行,如ReentrantLock) 和 Share共享
- AQS中通过使用一个volatile修饰的int类型的变量,通过该变量表示锁的状态,独占模式:通过是否=0 表示是否加锁
共享模式:int类型的变量是4个字节,32bit,使用高16位表示获取读锁的线程数量,低16位表示 获取写锁的线程数量(写锁独占模式)执行流程如下:
- 任务请求到达之后,第一个任务,尝试使用CAS修改stats的值;因为AQS内部维护了一个volatile修饰的int类型的变量,用该变量来表示是否加锁了
使用CAS尝试修改int变量,修改成功之后,即表示加锁成功;- 第二个任务达到之后,第一个任务还未释放锁,第二个任务,也会使用CAS尝试修改state的状态值,修改失败之后,会调用unsafe.park方法将当前线程
阻塞,阻塞之后,将其加入到CLH队列中- 第一个任务执行完成,释放锁之后,会唤醒队列中对应节点的后一个节点
ReentrantLock:
可重入锁,内部通过Sync实现公平锁和非公平锁,主要区别是抢占锁的方式不同
公平锁: 线程执行完成之后,调用unsafe.unpark会唤醒队列中该节点的next节点,唤醒之后,会尝试进行获取锁的操作,获取锁时,会进行判断该节点是不是需要排队 + CAS 修改锁的状态
非公平锁: 唤醒队列中节点线程之后,队列中的节点线程和新加入的线程会进行一个资源的竞争,故是非公平锁ws 状态说明:
- 0: 表示的是 等待状态
- -1:表示的是 挂起状态
释放锁干3件事情:
- 释放锁
- 根据头节点(前一个节点)状态(waitStatus)是否不等于0,选择性的唤醒
- 通过unsafe.unpark激活下一个节点线程
(公平锁)加锁操作:
- 加锁(节点是否要进行排队 + CAS 修改状态)成功后,直接开始执行操作
- 加锁失败之后,将当前线程挂起封装成Node节点,如果CLH队列为空,则会创建一个虚拟头结点作为当前节点的头结点
- 修改当前节点的前一个节点状态为signal 即ws=-1.
2. CountDownLatch 与 CyclicBarrier的区别?
countDownLatch:
- JUC并发包下面的一个同步工具类,基于AQS实现的
- CountDownLatch初始化时,需要指定一个count计数器,每当一个线程执行完成,count计数器减-1,直到计数器为0,等待线程才可以继续执行
- 计数器为0,不能重用
使用场景:
- 任务拆分并且汇总
- 统计多个线程的执行时间
常用方法:
- countDown(): 线程数量减1
- await(): 阻塞等待countDownLatch的计数器为0
Demo:
public class CountDownLatchTest { public static void main(String[] args) throws InterruptedException { AtomicInteger result = new AtomicInteger(100); CountDownLatch countDownLatch = new CountDownLatch(5); for (int i = 0; i < 5; i++) { new Thread(() -> { System.out.println("threadId => " + Thread.currentThread().getId()); result.getAndIncrement(); countDownLatch.countDown(); }).start(); } System.out.println("==== calc result ====="); countDownLatch.await(); System.out.println(result); } }
cyclicBarrier: 循环屏障
- JUC并发包下面的一个同步工具类,基于AQS实现
- cyclicBarrier可以让一组线程等待公共到达一个屏障点(barrier)之后,在全部同时执行,整个过程:分 => 合 => 分
使用场景:
- 多个线程之间相互等待
- 实现分段并发:多个线程执行并发执行每个阶段,待所有线程都执行完成当前阶段之后,在一起进入下一个阶段
常用方法:
await: 阻塞等待,等待所有的参与者都调用了该栅格的await方法
Demo:
public class CyclicBarrierTest { public static void main(String[] args) { // 到达屏障点的线程数量到达指定值3之后,才会执行barrierAction(屏障活动),活动结束之后继续执行await方法之后的操作 CyclicBarrier cyclicBarrier = new CyclicBarrier(3, () -> { System.out.println("屏障点活动...."); }); for (int i = 0; i < 3; i++) { new Thread(new ThreadSon(cyclicBarrier)).start(); } System.out.println("======= finish ========"); } } class ThreadSon implements Runnable { private CyclicBarrier cyclicBarrier; public ThreadSon(CyclicBarrier cyclicBarrier) { this.cyclicBarrier = cyclicBarrier; } @Override public void run() { try { // 屏障点之前的操作 System.out.println("Thread: => " + Thread.currentThread().getId()); // 阻塞等待线程到达屏障点 cyclicBarrier.await(); // 屏障点之后的操作 System.out.println("===开始执行=== " + Thread.currentThread().getId()); } catch (InterruptedException e) { throw new RuntimeException(e); } catch (BrokenBarrierException e) { throw new RuntimeException(e); } } }
3. Java并发编程-Condition(多线程条件对象)
- Condition位于Java.util.concurrent.locks包下
- 协调线程之间的执行顺序和通信
- 功能比较强大,可用于等待条件、通知单个线程、通知所有线程等
- 与Lock锁对象关联
- 通过await、signal、signalAll等方法进行线程间的通信
常用方法:
await: 挂起当前正在执行的线程,释放锁
signal:当条件满足时,选择一条等待该条件的线程进行通知,一般不推荐使用
signalAll:通知所有等待该条件的线程
Tip:
- 当某些条件满足时,线程应该通知正在等待该条件的其他线程,这时候,就需要使用signal或者signalAll
- Condition中await、signal、signalAll针对的是Lock,需要在lock、unlock之间使用;而Object中wait、notify、notifyAll针对的是synchronized关键字,需要在synchronized范围内使用
Demo:
- Condition中await、signal的使用
public class ConditionTest { private static volatile int flag = 1; public static void main(String[] args) { ReentrantLock reentrantLock = new ReentrantLock(); Condition firstCondition = reentrantLock.newCondition(); Condition secondCondition = reentrantLock.newCondition(); Condition threeCondition = reentrantLock.newCondition(); // 线程1 new Thread(() -> { System.out.println(1); reentrantLock.lock(); try { // 自旋方式 while (flag != 1) { firstCondition.await(); } System.out.println("==== 执行线程1的操作 ==== => " + Thread.currentThread().getId()); flag = 2; // 唤醒挂起的线程2 secondCondition.signal(); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { reentrantLock.unlock(); } }).start(); // 线程2 new Thread(() -> { System.out.println(2); reentrantLock.lock(); try { // 自旋方式 while (flag != 2) { secondCondition.await(); } System.out.println("==== 执行线程2的操作 ==== => " + Thread.currentThread().getId()); flag = 3; // 唤醒挂起的线程3 threeCondition.signal(); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { reentrantLock.unlock(); } }).start(); // 线程3 new Thread(() -> { System.out.println(3); reentrantLock.lock(); try { // 自旋方式 while (flag != 3) { threeCondition.await(); } System.out.println("==== 执行线程3的操作 ==== => " + Thread.currentThread().getId()); flag = 3; } catch (InterruptedException e) { throw new RuntimeException(e); } finally { reentrantLock.unlock(); } }).start(); } }
- Object中wait、notify的使用
public class SyncTest { private static volatile Integer flag = 1; public static void main(String[] args) { Object lock = new Object(); // 线程1 new Thread(() -> { try { synchronized (lock) { while (flag != 1) { lock.wait(); } System.out.println("=== 线程1的内容正在执行 === => " + Thread.currentThread().getId()); flag = 2; lock.notify(); } } catch (InterruptedException e) { throw new RuntimeException(e); } }).start(); // 线程2 new Thread(() -> { try { synchronized (lock) { while (flag != 2) { lock.wait(); } System.out.println("=== 线程2的内容正在执行 === => " + Thread.currentThread().getId()); flag = 3; lock.notify(); } } catch (InterruptedException e) { throw new RuntimeException(e); } }).start(); // 线程3 new Thread(() -> { try { synchronized (lock) { while (flag != 3) { lock.wait(); } System.out.println("=== 线程3的内容正在执行 === => " + Thread.currentThread().getId()); } } catch (InterruptedException e) { throw new RuntimeException(e); } }).start(); } }
4. MySQL 中BTREE索引解决了什么问题?
MySQL中BTREE索引解决的是通过降低BTREE树的高度,减少磁盘IO次数,从而优化查询效率。
通过B+TREE与B-TREE结构进行对比说明:
B-TREE(多路平衡搜索树):
- 多路平衡搜索树是一种常见的数据结构,主要是为一些外部存储设备设计的一种平衡搜索树,比如磁盘。
- 系统从磁盘读取数据,是按照磁盘块的大小进行读取的,并不是需要什么,就读取什么
- MySQL InnoDB存储引擎中是以页为单位进行数据加载的,磁盘块的内容往往是没有那么大的,所以在申请空间的时候,往往都是通过申请多个连续的磁盘块来达到页的大小
- BTREE的每个节点,都存储了主键信息、数据信息、地址指针信息等信息,如果数据信息过大,则会导致每次磁盘IO加载的数据变少,磁盘IO次数的变多,查询效率变低
B+TREE:
- B+TREE是对BTREE的一个优化,体现在节点中存储的内容不同
- 非叶子节点,只会存储主键KEY信息,不会存储地址指针信息、数据信息
- 叶子节点会存储完成的数据内容与主键信息,并且节点之间是通过双向链表进行连接,按照从小到大的顺序进行连接,所以分组查询、范围插件就会变得很简单
- 非叶子节点只存储KEY信息,所以进行磁盘IO操作的时候,每次加载的数据量就会变多,从而减少了磁盘IO次数,提高了检索速度
5.RabbitMQ 做【异步解耦】、【流量削峰】、【消息积压】?
RabbitMQ模型图:
异步解耦:
实战场景:
项目中使用RabbitMQ消息队列,存储第三方OA审批流程的节点转移信息,通过消费队列中信息,将其展示到本OA系统中
主要将一些不需要同步处理且耗时很长的业务分离出来,通过消息队列通知消息接受方进行异步处理
优势:
- 将第三方OA系统与本OA系统服务进行解耦
- 提升系统的响应时间,TPS(系统每秒中请求处理数量/每秒处理事务数量)
- 防止服务雪崩(由于某个问题出现故障或者宕机,导致依赖该服务的服务也无法正常运行,高并发环境下,导致服务发生级联问题,最终导致整个服务不可用)
重复消费解决方案:
目前主流的消息中间件中都不存在重复消息的解决方案,避免重复消费都是在服务端通过幂等操作来完成
服务端消费消息的幂等操作,解决方案:
- 方案一:消息全局ID(通用方案)服务端消费消息的时候,使用setnx命令在Redis中写入一个指定时间、指定messageId、指定状态的数据
# 指定messageId:order-1328897643784a # 指定value: 0:未消费 1:已消费 # 指定ex:防止服务端宕机之后,锁未释放,导致死锁 127.0.0.1:6379> set order-1328897643784a 0 ex 1000 nx
方案二:前置检查(支付宝铁律:一锁,二查,三操作)
利用数据库中表记录来实现,主要将消息存储到一张表中。每次消费消息之前,先加锁,然后在数据库中查询是否存在该消息记录,如果不存在,则消费该消息并记录消息消费记录
如果表中存在该消息记录,则证明该消息已经被消费
方案三: 数据库唯一索引(插入幂等)
这种主要利用的是数据库唯一索引特性,保证一张表中只能存在一条带有该唯一索引的记录,重复时,会直接报错
其表中唯一索引应采用分布式ID,保证在分布式环境下ID的全局唯一性
流量削峰:(削峰填谷)
*背景:*在抽奖或者秒杀系统中,活动开放的这段时间内,系统请求急剧增加将会出现一个波峰,而在活动未开始的时候,系统的请求量、机器负载一般都是比较平稳
概念: 使用某种技术手段,在活动开放的这段时间内,削弱系统瞬时的请求高峰,让系统吞吐量在高峰请求下保持可控
核心思想: 使用MQ中间件实现流量缓冲,大量请求直接操作数据库的话,直接会挂掉,MQ支持高并发处理,RabbitMQ每秒中可处理10W请求
解决方案:
- 使用消息中间件RabbitMQ,缓冲瞬时的流量,在消费者的上游添加MQ中间件,进行消息的缓存
- 对请求进行过滤,比如指定时间内,该IP发送的请求出现了多次,只处理第一次,后面的请求全部过滤掉
- 对请求进行限流熔断保护,将超过负载能力的请求直接过滤掉,尽可能达到消费者的请求是真实的
消息积压:
原因:
- 生产者生产消息的速率远远大于消费者消费的速度,随着时间的积累就会出现该情况
- 虽然消息中间件有一定的缓存能力,但是容量是有限制的,消息长期不进行处理的话,最终会导致Broker奔溃,从而引发一些列问题…
解决方案:
- 消息限流处理
- 生产者:
- RabbitMQ中可以对内存、磁盘使用量设置阈值,当达到阈值之后,生产者会被阻塞,直到系统对应的指标值恢复到正常水平
- 基于Credit Flow流控处理,这种方式针对的是每一个连接,如果单个连接的消息流速达到最大值或者多个消息的流速的总值达到了最大流速,都会触发一个流控,即Message rate 为0
- 消费者:
- Qos保证机制,该机制是通过限制Channel信道上消费者未ack确认的消息数量的方式限制给消费者推送消息
- 提升服务消费者(下游服务)的TPS
- 增加消费者的数量
- 调整并发消费的线程数量
- 优化消费消息的应用程序性能
6. JVM虚拟机调优从哪些方面考虑?
为什么需要JVM调优?
- 随着时间的推移,并发请求的增加,会导致系统吞吐量(TPS)下降
- 原因:并发请求增加,处理请求的线程数增加,内存中创建对象的数量就会增加,会频繁的进行GC,增加STW
- 避免内存泄漏
- 原因:Java中常见的内存泄漏问题会导致系统运行时间越长,占用的内存空间越多
- 避免资源浪费(线程方面)
- 原因:Java中线程属于昂贵资源,不要频繁的创建、销毁线程,这样会非常消耗系统的资源(建议使用线程池进行操作,这样避免线程频繁的创建、销毁,线程复用)
调优策略:
Tip: 既然知道了为什么需要JVM调优,针对具体的原因采取方案即可
- 减少GC次数,减少STW时间
- 解决方案
- 调整堆内存空间,堆内存太小的话,会频繁的进行GC,如果太大的话,会造成资源浪费
- -Xms:初始堆大小
- -Xmx:最大堆大小
- -Xmn:年轻代大小
- 针对年轻代、老年代的特点,调整新生代和老年代内存比例,可以做到垃圾回收频率和TPS的一个平衡
- 新生代对象存活时间比较短,但是新生代对象的创建频率比较高
- 老年代对象存活时间比较长
- 根据应用程序的特点,选择合适的垃圾回收器
- 想要高TPS,可选择CMS垃圾回收器
- 想要控制垃圾量,可以选择G1垃圾回收器
- 调整垃圾回收器参数
- 通过配置参数,优化垃圾回收器的新能
- 避免资源浪费
- 解决方案
- 合理的配置线程数量,避免创建过多线程导致资源竞争和上下文切换的开销
- 使用线程池管理线程,避免线程的创建和销毁的开销
- 资源监控和分析
- 采用Jconsole、VisualVM等工具监视应用程序的性能指标和资源使用情况
- 分析堆栈和GC日志,发现性能瓶颈和内存泄漏问题
- 代码级别的优化
- 避免对象的频繁创建和销毁,尽可能的重用对象或者使用对象池