⭐乐观锁与悲观锁
乐观锁与悲观锁的区别?
- 悲观锁:共享资源每次只给一个线程使用,其他线程阻塞,用完之后再把资源让给其他资源。但是读写锁可以多个线程同时读。
- 乐观锁:共享资源每次可以给多个线程使用,只是再提交修改的时候去验证资源是否被其他线程所修改。
使用场景:
- 悲观锁:适合写多读少的情况。
- 乐观锁:适合读多写少的情况。
缺陷:
- 悲观锁:高并发下,激烈的锁竞争会造成线程阻塞,大量的线程会导致系统上下文切换频繁,增加系统的性能开销;还有可能导致死锁。
- 乐观锁:高并发下,相比悲观锁来所,不存在锁竞争造成线程阻塞,也不会有死锁问题,性能往往更好。但是再写冲突高的场景下可能会导致ABA问题,性能忽高忽低等
CAS了解吗?原理是什么?
乐观锁一般会使用版本号机制或者CAS算法实现,CAS算法使用会多一些。值得一提的是,这里的版本号机制和CAS算法中的ABA问题的解决方案是同一个东西。
CAS算法的思想很简单,就是用一个预期值和要更新的变量值进行比较,两个值相等才会进行更新。这里的比较和修改两个步骤再CPU硬件层面是一条原子指令,是不可拆分的,这也是CAS算法的基础。
比如ConcurrentHashMap采用的就是CAS和synchronized来保证并发安全的。
原子类也是
⭐JMM(Java memory model)
并发编程的三个重要特性
- 原子性:一个或者多个操作要么全部执行且不中断,要么全部不执行。
- 可见性:当一个线程修改了共享变量,其他线程能够立刻看到这个修改。
- 有序性:编译器优化和指令重排在多线程情况下,可能导致意外的结果。因此保证有序性可以禁止指令进行重排序优化。
实现手段
- 原子性:一般都是通过加锁去实现,当然也可以使用CAS实现。
- 可见性:
- 使用
volatile关键字 synchronized和lock同步块final关键字再构造函数中有正确初始化(没有this逃逸)
- 使用
- 有序性:
volatilesynchronized和lock同步块happens-before原则:在多线程环境中,当两个操作之间存在happens-before关系时,前一个操作的修改对后一个操作是可见的。
什么是JMM?为什么需要JMM?
要了解JMM,就要了解计算机内存的发展史。
- 第一阶段:cpu从磁盘中读取数据,但是读取的速度太慢,使其cpu的性能大大浪费;
- 第二阶段:再cpu和磁盘中间增加了内存(主存),用以加快读取过程,但此时cpu的性能依旧没有得到充分的利用;
- 第三阶段:给cpu里面增加了缓存(一级、二级、三级),使其读取速度非常高。但是带来了缓存一致性的问题。
了解缓存一致性问题:
- 情况一:一个线程修改了缓存但是没有同步到主存,其他的cpu核心再去读取数据就是读取的脏数据,这也是可见性的本质。
- 情况二:再执行代码的时候,cpu或者编译器可能会对指令进行重新排序,导致执行的顺序和代码的顺序不一致。这只能保证单线程串行执行的语义一致,但是没有办法保证多线程并发执行的语义一致,这也是有序性的本质。
- 情况三:线程A读取到的库存为1,要进行减1的操作,但是只是读取到了还没执行,cpu时间片用完了。此时线程B拿到库存减1,将库存边为了0。当线程A回来执行的时候,再减1,此时的库存为-1。不符合预期,库存不能为负数,这也是原子性的本质。
所以整个并发编程的核心就是要去解决可见性、有序性、原子性问题。操作系统为了去解决这些问题,定义了一些规范。但是不同的操作模型内存模型是不同的。如果Java直接服用操作模型的内存模型去解决这些问题,就可能会导致同一套代码在不同的系统上可能运行结果不一样。但是Java的口号就是一次编译处处运行,所以Java就自己去提供了一套内存模型去屏蔽不同操作系统的差异。
JMM是如何抽象线程和主内存之间的关系?
知道了为什么需要JMM,那JMM是如何去解决这些问题的呢?JMM定义了一套并发编程相关的规范,抽离了缓存、线程、主存之间的关系去解决这些问题。
具体抽象过程:
┌─────────────┐ ┌─────────────┐
│ 线程1 │ │ 线程2 │
│ ┌─────────┐ │ │ ┌─────────┐ │
│ │本地内存 │ │ │ │本地内存 │ │
│ │ ┌────┐ │ │ │ │ ┌────┐ │ │
│ │ │共享│ │ │ │ │ │共享│ │ │
│ │ │变量│ │ │ │ │ │变量│ │ │
│ │ │副本│ │ │ │ │ │副本│ │ │
│ │ └────┘ │ │ │ │ └────┘ │ │
│ └─────────┘ │ │ └─────────┘ │
└──────┬──────┘ └──────┬──────┘
│ │
└────────────┬─────────────────┘
│
┌──────────▼──────────┐
│ 主内存 │
│ ┌──────┬──────┬──────┐
│ │共享 │共享 │共享 │
│ │变量1 │变量2 │变量3 │
│ └──────┴──────┴──────┘
└─────────────────────┘
- 将寄存器、cpu缓存这些东西统一抽象为线程的工作内存(本地内存),这是线程私有的一个计算区域,每个线程的工作内存相互独立。线程必须把主存中的数据读取到自己的工作内存中去计算,要与其他线程共享数据时,必须将计算后的结果刷新回主存中,其他线程再去读取。
- 抽象完成之后,就需要去定义工作内存和主内存之间要怎么进行交互。JMM规定了八种主存和工作内存之间的交互方式,如
lock、unlock、read等。 - 虽然定义了交互的方式,但是没有定义什么时候做,具体怎么实现顺序的保障。所以JMM需要通过内存屏障去保证交互的顺序性和可见性。这是一种cpu指令,主要有四种,都是为了禁止重排序。
- 但是从程序员写代码的角度来看,还是不知道编译器和处理器是怎么重排序的,怎么优化的。不知道实际的执行顺序就不知道线程A做的操作,线程B是否可见。因此JMM规定了happens-before规则(有八条),这个一个可见性的逻辑抽象,它不管底层是怎么实现的,只管 A happens before B,那么A的结果对B一定是可见的。
在知道happens-before规则时候,只需要知道操作满足规则,那就有顺序性的保证,那就是可见的。否则JVM就可以对指令进行重排序。
synchronized是什么?怎么使用?
synchronized是个锁,使这段代码同一时间只能被一个线程访问,这就是原子性。它编译之后就是monitor enter 和 monitor exit 两个指令。enter就是加锁,加锁的时候会使用读屏障,强行从主存中重新读取数据。exit是解锁,解锁的时候回使用写屏障,强制将cpu缓存中的变量刷新到主存中。这就是保证了可见性,同时也通过内存屏障防止指令重排,这就是有序性。
如何使用:
- 它修饰普通方法锁就是this,就是当前对象;
- 修饰静态方法,锁是类.class对象;
- 代码块,括号里面写什么它就锁什么;
什么是锁升级
要了解锁升级,就需要了解jdk1.6之前的synchronized的逻辑:
- 加锁本质上是调用操作系统底层原语mutex;
- 同时又涉及到线程的阻塞和唤醒;
- 但是Java线程是一对一的模型,每一个线程都直接对应一个操作系统的内核级线程,每次阻塞切换线程都需要操作系统从用户态切换到内核态,开销很大。
然而很多系统大多数时间并没有锁竞争,只在特定的时间并发量大,比如周末、618等。因此为了节约资源,低峰时期可以无锁,高峰时期为了应对锁竞争越来越激烈过程,从无锁升级到偏向锁、轻量级锁、重量级锁。
具体优化过程:
- 无锁:当只有一个线程来访问代码块时,JVM将对象头的Mark Word 锁标志位设置为偏向锁,然后将线程id记录到mark word里面,这个时候这个线程进入这个同步代码块就不需要其他同步操作了;
- 偏向锁:针对的是只有一个线程来抢锁的情况,当又第二个线程来抢锁的时候,就升级为轻量级锁;
- 轻量级锁:第二个抢锁的线程拿不到锁,就会采用cas加自旋的方式去获取锁。这种情况是属于锁不多占用时间也不长的情况,可以避免阻塞导致的用户态和内核态的切换产生的开销;
- 重量级锁:当第二个抢锁的线程自选到一定次数或者又更多的线程来抢锁,就会升级到重量级锁。此时对象头的Mark Word 的指针就会指向锁监视器monitor(负责记录锁的拥有者,锁的重入次数,负责线程的阻塞和唤醒)
⭐ThreadLocal
ThreadLocal有什么用?
**ThreadLocal 是 Java 中实现线程局部存储的工具:**每个线程都拥有自己独立的变量副本,线程之间互不干扰。简单说,同一个 ThreadLocal 变量在不同线程中存取的是不同的值。
ThreadLocal的原理了解吗?
- 存储方式:每一个线程里面有一个ThreadLocalMap,本质是一个hashmap,底层使用Entry数组来存储数据。其中entry的key是一个ThreadLocal的弱引用,value就是资源。
- key内存泄漏:在使用线程池的情况下,线程会一直被复用。这会导致ThreadLocal的强引用不在了,但是其他的引用链还是存在的,这也是为什么entry的key要设计为弱引用,这样gc回收的时候,key就能被垃圾回收,从而避免内存泄漏。
- key回收:只剩下entry的key的弱引用,没有其他强引用的时候,gc才能回收key。
- value清洗:当我们调用get或者set方法时候,就会遍历entry数组,清理掉key为null的entry。
- value强引用:如果value是弱引用的话,gc的时候没有强引用会导致被垃圾回收,导致处理业务过程中数据丢失。
- value内存泄漏:因此使用完之后,必须手动断开value的强引用(remove),然后让gc去回收掉。
- threadLocal和锁:都是为了解决并发安全;一个是访问同一个共享资源,另一个是复制资源各不干扰。
⭐线程池
为什么不推荐使用内置线程池?
队列的长度、线程的数量默认太大,可能会导致OOM
线程池常见的参数有那些?
| 参数名 | 类型 | 作用 |
|---|---|---|
corePoolSize |
int |
核心线程数 |
maximumPoolSize |
int |
最大线程数 |
keepAliveTime |
long |
非核心线程空闲存活时间 |
unit |
TimeUnit |
keepAliveTime 的时间单位 |
workQueue |
BlockingQueue<Runnable> |
任务阻塞队列 |
threadFactory |
ThreadFactory |
线程工厂(用于创建线程,可自定义名称、守护状态等) |
handler |
RejectedExecutionHandler |
拒绝策略(当队列满且线程数达到最大值时执行) |
拒绝策略有哪些?
| 策略类 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy(默认) |
直接抛出 RejectedExecutionException 运行时异常 |
关键业务,不允许丢弃任务,需要上游感知并处理 |
CallerRunsPolicy |
由提交任务的线程(调用者)自己执行该任务 | 降低新任务提交速度,避免系统过载,适合对延迟不敏感、允许降级的场景 |
DiscardPolicy |
静默丢弃新提交的任务,不抛异常 | 非核心、可丢失的任务(如日志、埋点) |
DiscardOldestPolicy |
丢弃队列头部(最旧)的未处理任务,然后重新尝试提交当前任务 | 类似 DiscardPolicy,但优先保留新任务 |
⭐AQS
AQS是什么?AQS原理是什么?
AQS 是 AbstractQueuedSynchronizer 的缩写,是 Java 并发包(java.util.concurrent)中几乎所有锁和同步器的底层基础框架。ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 等都是基于 AQS 实现的。
原理:
- state:
- 其值为0,表示资源空闲;其值为1,表示资源被占有;这是ReentrantLock的思想。
- 可是就两个值为什么不设计为Boolean?那是因为在共享资源可以同时被n个线程访问的情况下,此时state的值为n,这就是semaphore的设计思想。
- 递归场景下,当前线程多次访问共享资源,访问一次state就要+1,释放一次就要-1,当state为0代表资源空闲。这是reentrantLock可重入的设计思想。
- 由于是多线程下,要保证可见性,所以该状态变量加上了volatile去保证可见性。
- 那原子性如何保证呢?这里使用的是cas去修改,来保证原子性。但是cas一直失败,就会阻塞放入队列FIFO中,等待被唤醒。
- tryAcquire:用于实现具体的获取共享资源的方式,AQS只需要知道有没有获取成功。
- acquire:会调用tryAcquire去获取资源,获取不到就阻塞。
- 公平性:FIFO队列为实现公平性提供了基础,实现类也可以自定义实现tryAcquire方法去自定义公平和非公平策略;
- ReentrantLock就是这么做的,它有一个FairSync和NonFairSync的子类,就是实现的tryAcquire方法。
- 共享和独占:
- 共享:多个线程共享资源,比如semaphore或者CountDonwLatch;
- 独占:只允许一个线程独占资源,对应AQS的acquire和tryAcquire,在reentrantlock里的表现就是lock和trylock。
- condition:用于管理调用了await的线程,将其放入条件队列中;







