JUC并发编程与源码分析
基础#
JUC是java.util.concurrent
在并发编程中使用的工具包。
线程的start()
方法底层使用本地方法start0()
调用C语言接口,再由C语言接口调用操作系统创建线程。
public class demo(){
public static void main(Strings[] args){
Thread t1 = new Thread(() -> {
System.out.println("启动线程");
},"t1").start();
}
}
管程,monitor,也就是我们平时所说的锁。
CompletableFuture#
CompletableFuture提供了一种观察者模式类似的机制,可以让任务执行完成后通知监听的一方。
public class CompletableFuture<T> implements Future<T>,CompletionStage<T>
。
CompletableFuture的默认线程池是守护线程,因此在主线程结束时会跟着结束,因此最好使用自定义的线程池。
CompletableFuture的优点:异步任务结束时,会自动回调某个对象的方法。
join()
方法与get()
方法类型,只是不会抛出编译时异常。
合并结果使用thenCombine()
方法。
public class CompletableFutureDemo{
public static void main(String[] args) throws Execption{
ExecutorSevice threadpool = Executors.newFixedThreadPool(3);
CompletableFuture<int> completablefuture1 = CompletableFuture.SupplyAsync(() -> {
int result = 7;
return result;
},threadpool).whenComplete((result,exception) -> {
if(exception == null){
System.out.println(result);
}
//关闭线程池
threadpool.shutdown();
});
}
}
Future接口#
Future接口(FutureTask实现类)定义了操作异步任务执行一些方法,如获取异步任务的执行结果、取消任务的执行、判断任务是否被取消、判断任务执行是否完毕等。
Future的缺点是get()
方法容易导致阻塞,isDone()
方法轮询问题,因此对于结果的获取不友好。
public class CompletableFutreDemo(){
public static void main(String[] args) throws Exception{
FutureTask<String> futureTask = new FutureTask<>(new Mythread());
Thread t1 = new Thread(futureTask);
t1.start();
System.out.println(futureTask.get());
}
}
public class MyThread implements Callable<String>{
@Override
public String call() throws Exception{
System.out.println("进入方法");
return "hello";
}
}
函数式接口#
函数式接口名称 | 方法名称 | 参数 | 返回值 |
---|---|---|---|
Runnable | run | 无 | 无 |
Function | apply | 1 | 有 |
Consumer | accept | 1 | 无 |
Supplier | get | 无 | 有 |
BiConsumer | accept | 2 | 无 |
锁#
synchronized的方法并不影响普通方法的使用。
对于普通同步方法,锁的是当前实例对象,通常指this,所有的普通同步方法用的都是同一把锁,即实例对象本身。对于静态同步方法,锁的是当前类的Class对象。对于同步方法块,锁的是synchronized括号内的对象。
具体实例对象this和唯一模板CLass,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞态条件的。
通过反编译,synchronized的底层实现是monitorenter和monitorexit指令,而且会有两个monitorexit以保证能够正常退出。对象锁会有ACC_SYNCHRONIZED
标记位。
每个对象都带有一个对象监视器,每个锁住的对象都会与Monitor关联起来。在java的头文件中存储了锁的相关信息,每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
ReentrantLock(true)
可以将非公平锁设置为公平锁。公平锁会按照先到先得的顺序获取锁。默认是非公平锁,因为能更充分地利用CPU资源,而且减少线程的切换。
可重入锁,又称为递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象)不会因为之前已经获取过还没释放而阻塞。
LockSupport与线程中断#
线程中断#
一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止。
中断只是一种协作协商机制,Java没有给中断增加任何语法,中断的过程完全需要程序员自己实现,interrupt()
方法仅仅是将线程对象的中断标识设置为true。
Thread.interrupted()
静态方法判断线程是否被中断并清除当前中断状态。
中断运行中的线程:使用volatile变量、使用具有原子性的AtomicBoolean和Thread的API方法,思路都是使用标记退出程序。
如果线程处于被阻塞状态,在别的线程中调用当前线程对象的interrupt()
方法,那么线程将清除中断状态,立即退出被阻塞状态,并抛出InterruptedException
异常。中断不活动的线程不会产生任何影响。
LockSupport#
LockSupport是用来创建锁和其他同步类的基本线程阻塞原语,其中park()
方法和unpark()
方法的作用分别是阻塞线程和解除阻塞线程。
阻塞和唤醒线程的方法:使用Object中的wait()
方法让线程等待和notify()
方法唤醒线程,与synchronized搭配使用。使用JUC包中Condition的await()
方法让线程等待和使用signal()
方法唤醒线程,与ReentrantLock搭配使用;LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程。
由于LockSupport底层使用类似信号量的机制,每个最多只有一个通行证,因此unpark()
可以在park()
前执行。
//ReentrantLock的使用
public class CompletableFutreDemo(){
public static void main(String[] args) throws Exception{
Lock UseLock = new ReentrantLock();
Condition condition = Uselock.newCondition();
new Thread(() -> {
UseLock.lock();
condition.await();
UseLock.unlock();
},"t1").start();
new Thread(() -> {
UseLock.lock();
condition.signal();
UseLock.unlock();
},"t2").start();
}
}
//LockSupport的使用
public class CompletableFutreDemo(){
public static void main(String[] args) throws Exception{
Thread t1 = new Thread(() -> {
LockSupport.park();
},"t1");
t1.start();
//而且即使unpark()先执行,park()仍能成功执行。
new Thread(() -> {
LockSupport.unpark(t1);
},"t2").start();
}
}
Java内存模型JMM#
由于CPU的运行是先把内存中的数据读到缓存,因此JVM试图使用JMM来屏蔽掉各种硬件和操作系统的内存访问差别。
JMM本身是抽象的概念,是一组约定,围绕多线程的原子性、可见性和有序性展开。
JMM三大特性:可见性、原子性和有序性。
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更。
指令重排可以保证串行语义一致,但并不保证多线程的语义一致。
从源代码到最终执行:源代码->编译器优化的重排->指令并行的重排->内存系统的重排->最终执行的指令。
volatile#
volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
volatile拥有可见性和有序性,但不具备原子性。
内存屏障是CPU或编译器在对内存随机访问的操作中的一个同步点,使得之前的所有读写操作都执行后才可以执行改点之后的操作。
内存屏障分为读屏障和写屏障。读操作会在后面加上两个内存屏障,写操作会在前后各加上一个内存屏障。
public class VolatileTest{
int i = 0;
volatile boolean flag = false;
public void write(){
//i和flag由于没有数据依赖性,因此可能发生指令重排序
i = 2;
//但volatile会在flag=true前加入StoreStore屏障禁止上面的普通写与下面的volatile写重排序,后面假设StoreLoad屏障禁止下面可能有的volatile读写重排序
flag = true;
}
public void read(){
//在每一个volatile读操作后面插入一个LoadLoad屏障,禁止处理器把下面的普通读重排序。在每一个volatilei读操作后面插入一个LoadStore屏障,禁止与下面的普通写重排序
if(flag){
//如果flag先赋值为true,那么会打印i=0。
System.out.println("i="+i);
}
}
}
Java内存模型中定义的8种每个线程自己的工作内存与主物理内存之间的原子操作:read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)→lock(锁定)→unlock(解锁)。
步骤中包含加锁和解锁但没有保证原子性的原则是在读取和写入的阶段加锁,但是保证原子性要将读取、计算、写入这整个阶段才能保证。因为在A读,B读,A计算,B计算,A写入,B写入,A的操作就被覆盖了。因此volatile变量不参与变量的计算,常用于布尔值标记。
在单例模式中,在创建实例singleton = new SafeDoubleCheckSingleton()
的时候会有三个步骤,开辟内存空间、新建对象,将指针指向新建对象。但是由于指令重排,第二和第三步逆转会导致其他线程获取到空对象,因此需要将singleton声明为volatile。
volatile写之前的操作都禁止重排序到volatile之后;volatile读之后的操作都紧张重排序到volatile之前。
CAS#
CAS(compare and swap),比较并交换,包含三个操作数:内存位置、预期原值及更新值。
CAS是CPU的原子指令(cmpxchg指令),由Unsafe类的本地方法调用,其内部方法操作可以像C的指针一样直接操作内存。
AtomicInteger主要利用CAS、volatile和native方法保证原子操作。
CAS是靠硬件实现的从而在硬件层面提升效率,最底层还是交给硬件来保证原子性和可见性。
public class SpinLock{
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void lock(){
do{
Thread thread = Thread.currentThread();
}while(!atomicReference.compareAndSet(null,thread));
}
public void unLock(){
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread,null);
}
}
原子操作类#
可以使用countDownLatch
类来记录线程个数,用于等待线程全部执行后输出结果。
CAS的缺点是循环时间开销大和ABA问题。ABA问题可以通过添加版本号解决,AtomicStampedReference
,或者使用标记位,AtomicMarkableRefence
。
原子类有针对属性修改的原子类,便于更小粒度的加锁范围,要求更新的对象属性必须使用public volatile
修饰符,且因为对象的属性修改类型原子类都是抽象类,所以每次使用都必励使用静态方法newUpdater()
创建一个更新器,并且需要设置想要更新的类和属性。
class BankAccount{
String bankName = "CCB";//不需要特殊处理的属性
public volatile int money = 0;//需要使用public volatile
//使用newUpdater设置类和属性
AtomicIntegerFieldUpdate<BankAccount> fieldUpdate = AtomicIntegerFieldUpdate.newUpdater(BankAccount.class,"money");
public void transMoney(BankAccount bankAccount){
filedupdater.getAndIncrement(bankAccount);
}
}
如果是JDK8,推荐使用LongAdder对象,比AtomicLong性能更好,原因在于化整为零,最后再求和。LongAdder只能用来计算加法,且从零开始计算,而LongAccumulator提供了自定义的函数操作。
LongAdder是Striped64的子类。最重要的属性是cell数组和base,统计求和:base+cell数组,cell数组中的元素格式是2的倍数。
sum执行时,并没有限制对base和cells的更新。所以LongAdder不是强一致性的,它是最终一致性的。
ThreadLocal#
ThreadLocal提供线程局部变量。每个线程在访问ThreadLocal实例的时候都有自己独立初始化的变量副本。
class House{
Threadlocal<Integer> saleVolume = ThreadLocal.withInitial(() -> 0);
public void saleVolumeByThreadLocal(){
saleVolume.set(saleVolume.get()+1);
}
}
如果在线程池中使用到ThreadLocal,在使用结束后一定要使用remove清除,否则会造成问题。
threadLocalMap实际上就是一个以threadLocal实例为key,任意对象为value的Entry对象。
虚引用必须和引用队列(ReferenceQueue)联合使用;Get方法总是返回null,处理监控通知使用。
ThreadLocal使用弱引用,以避免内存泄漏,由于有线程的强引用指向ThreadLocal对象,因此不用担心在使用过程中被回收掉。
每个线程持有一个只属于自己的专属Map并维护了ThreadLocal对象与具体实例的映射,该Map由于只被持有它的线程访问,故不存在线程安全以及锁的问题。
对象内存布局与对象头#
虚拟机要求对象起始地址必须是8字节的整数倍。对象标记8个字节,类型指针8个字节。
Synchronized与锁升级#
锁升级过程:无锁、偏向锁、轻量级锁、重量级锁。
偏向锁:MarkWord存储的是偏向的线程ID;轻量锁:arkWord存储的是指向线程栈中Lock Record的指针;重量锁:MarkWord存储的是指向堆中的monitor对象的指针。
偏向锁在默认情况下会延时4秒启动,可以立即启动或者选择关闭。从java15逐步废弃偏向锁。
竞争线程尝试CAS更新对象头失败,会等待到全局安全点(此时不会扰行任何代码)撤销偏向锁。
java6之后,自旋次数是自适应的,如果自旋成功,下次自旋的最大次数会增加,反之会减少。
锁升级为轻量级或重量级锁后,Mark Word中保存的分别是线程栈帧里的锁记录指针和重量级锁指针,已经没有位置再保存哈希码, GC年龄。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了:而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在轻量级锁会在当前线程的栈帧中创建一个锁记录空间,用于存储锁对象的Mark Word拷贝;重量级锁的ObjectMonitor类里有字段记录非加锁状态下的Mark Word。
编译期间会进行锁消除和锁粗化的优化。
AQS(AbstractQueuedSynchronizer)#
AQS是用来实现锁或者其它同步器组件的公共基础部分的抽象实现,是重量级基础框架及整个JUC体系的基石,主要用于解决锁分配给"谁"的问题。整体就是一个抽象的双向FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量表示持有锁的状态。
AQS将要请求共享资源的线程及自身的等待状态封装成队列的结点对象(Node),通过 CAS、自旋以及LockSupport..park()的方式,维护state变量的状态,使并发达到同步的效果。
ReentrantLock中的sync属性继承AQS,根据是否公平锁,有fairSync和NonfairSync继承sync。在创建完公平锁/非公平锁后,调用Iock方法会进行加锁,最终都会调用到acquire方法。
node加入队列后会进行一次自旋尝试获取锁,如果不成功node节点使用LockSupport进行阻塞。
整个ReentrantLock的加锁过程,可以分为三个阶段:1、尝试加锁;2、加锁失败,线程入队列;3、线程入队列后,进入阻塞状态。
线程入队前会先判断队列是否已经初始化,不为空则将node插入到末尾,否则将执行队列的初始化。
自旋的时候会判断前辈节点是否head和判断前辈节点的waitStatus,因为第一次自旋会将前辈的waitStatus修改为signal,第二次自旋就会根据这个标志位将node阻塞。
waitStatus#
枚举 | 含义 |
---|---|
0 | 初始化后的默认值 |
CANCELLED(1) | 线程获取锁的请求已经取消 |
CONDITION(-2) | 节点在等待队列,等待唤醒 |
PROPAGATE(-3) | 线程在SHARED情况,会进行传播 |
SINGAL(-1) | 线程已准备好,等待资源释放 |
读写锁#
无锁->独占锁->读写锁->邮戳锁的演化。
读写锁:一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。
读写锁的缺点在于写锁饥饿问题。可以通过设置为公平锁轻微缓解。
读写锁常用的实现类是ReetrantReadWriteLock
。
锁降级:线程获取写锁后,在释放写锁前先获取读锁,这样就实现了写锁向读锁的降级。
ReadWriteLock rwlock = new ReentrantReadWriteLock();
rwlock.readlock().lock();
rwlock.writelock().lock();
StampedLock
是JDK新增的一个读写锁,对读写锁reentrantReadWriteLock
的优化,增加了乐观读的模式。采取乐观策略获取锁后,其他线程尝试获取写锁时不会被阻塞,因此在获取乐观读锁后,还需要对结果进行校验。
StampedLock
是不可重入锁,因为重复获取写锁会造成问题。stamp代表了锁的状态。当stamp返回零时,表示线程获取锁失败,当释放锁或者转换锁的候,都要传入最初获取的stamp。
StampedLock
的缺点是不支持重入、不支持条件变量,且使用时不要调用中断操作。
StampedLock stampedLock = new StampedLock();
//传统写锁
long stamp = stampedLock.writeLock();
stampedLock.unlockWrite(stamp);
//乐观策略
long stamp = stampedlock.tryoptimisticRead();
if(!stampedLock.validate(stamp)){
stamp = stampedLock.readLock();//如果发生变化则取消使用乐观模式
}
作者:xiqin
出处:https://www.cnblogs.com/xiqin-huang/p/18022435
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 单元测试从入门到精通