1.概念
1.进程
一个程序运行的基本单位。包括计算机全部资源 + 程序。【cpu、内存、外设】
2.线程
实现cpu的虚拟化。同一个进程下的线程共享内存,但是每个线程有独立的栈 <=> 看起来好像有一个自己的cpu。【会将cpu的上下文内容储存在栈中】
【之所以出现线程是因为:一个程序可能需要实现不同的功能,或者需要同时处理多个任务,但是又需要这些功能共享内存,如果创建多个进程,开销会很大,而且进程通信的代价高,因此轻便的多线程由民间走进官方视野】
3.并发
是指同时开始,但是每一个时刻做的只有一个,每一个做一个小时间片。【单核多线程就可以】
4.并行
同时运行。在一个时刻会有多个在同时执行。【这个需要多核来实现】
进程中的多个线程就是并发执行的,但是由于他们共享资源,就产生了一些安全问题。
image一个具体场景:
假设内存中有一个变量num = 0;线程AB对其做同样的工作,对num++线程A将A拿出++之后,时间片用尽;开始执行线程B,也取出num++;这时应当发现问题因为线程A操作完之后没有讲数据还回内存,线程B就打断了它,这是线程B看到并操作的num = 0;相当于A、B都执行的是将num 从0 变成1。之后可以判断,A将1写回,B也将1写回内存。最终num = 1。
5.线程安全问题/临界区问题
1)问题产生原因剖析:
线程对资源产生争夺。在争夺过程中被别的线程打断了执行。修改值包含取出——修改——写回3步。取出、修改执行完被打断,还没写回更新就被打断了,这个时候内存的值已经不是最新的了,但是其他线程并不知道。
=>需要保证争夺资源的这部分代码变成原子化,eg:取出——修改——写回一块执行完而不能被其他线程打断。
2)解决线程安全问题的方法
加锁的思想 —— 将操作原子化
(1)同步代码块 synchronized
(2)同步方法
(3)lock变量【c++直接用std::mutex】
纯纯将操作原子化
(1)CAS操作
2.锁机制
1.实现方法:
同步代码块
synchronized (监视器) {
被锁起来的代码
}
1.监视器一定是唯一的,可以被所有线程看到的,否则不能实现执行这段代码时只有一个线程在执行不被打断(因为其他线程可能看不到锁)
2.任何一个对象都可以做监听器
同步方法
provide synchronized void 函数名(){
函数体
}
1.可以在implement Runable接口中重写run实现使用
手动加锁,释放锁
privateLocklock=newReentrantLock();//创建一个lock对象,因为Lock是一个接口 注意new的是ReentrantLock,而不是Lock
……
lock.lock();
被锁起来的代码
lock.unlock()
1.lock效率比较低
2.手动释放锁的时候很有可能会忘记释放建议使用try{ 上锁代码 }finall{ lock.unlock}
try{
lock.lock();
……
}
finally{
lock.unlock();
}
3.如果忘记释放锁会导致上锁之后部分的代码一直处于单线程状态,别的线程无法执行,效率低下。
2.上锁机制
在之前的文章中有总结过一部分较为详细。接下来我完善一些细节。
首先我们清楚了 synchronized 的实现历程从无锁 <—— 偏向锁 <—— 轻量锁 <—— 重量级锁。以及产生的大致缘由:下面详细解释这几种锁以及此时当一个线程来争夺锁的流程细节。
讲锁之前先要熟悉一个概念 —— CAS操作
CAS:【compare and swap 比较交换】。实现给某个内存原子化换值【之前都是取出——修改——写回,不安全】。
该操作保存:一个内存地址addr,之前保留的这个地址存储的值old_value,要修改成的新值new_value。
操作:比较addr这个地址内存储的值和我之前存的old_value值是否还保持一致,如果一样就把它换成new_value。不一样就会放弃操作(不一样可能是因为有线程争夺到并修改了值)。这下只写new_value是原子的。
重量级锁
我们熟悉的锁操作,当线程争夺资源时,发现资源被其他线程上了锁,就要被阻塞等待,直到当前线程释放锁被唤醒重新争夺锁。
其底层通过监视器锁(monitor) —— OS互斥锁(mutex)实现。
轻量级锁
如果一争夺就要去睡,然后被唤醒这样需要切换到kernel去执行,频繁地切换会消耗大量资源(浪费时间、CPU等),如果是很快就释放了锁岂不是代价太大了,因此提出轻量级锁。当我争夺资源的时候,如果发现被锁了,我先等等看,一定时间后再查看一下是否释放了锁,如果释放了则参与争夺;如果这个等待时间短于我阻塞切换的时间,就赢了。
偏向锁
这样等等,等不到再去阻塞还不够,当只有一个线程反复争夺锁的时候还可以继续优化,反正只有一个线程争夺锁,就不要再加锁机制了,就记录下线程id,下一次来再获取的时候,对比看看是不是还是当 前线程线程 上次拥有的资源,如果是就直接执行,如果不是说明这个资源已经被争夺了。那就立刻升级成为轻量级锁。
无锁
当一个线程没有要争夺的资源时是处于无锁状态的,此时所有线程都可以获取这个资源。
于是当一个进程被执行的时候就会走下面的流程:
1.一个进程上锁会在线程的栈帧里创建lockRecord,在lockRecord里和锁对象的MarkWord里存储线程a的线程id。一个线程执行:CAS操作测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,成功就是当前线程上锁(偏向锁)【可能情况是:当前线程号为null,或者线程号一致】;否则就是另一个线程锁着,检查这个原来持有该对象锁的线程是否依然存活,挂了,则可以将对象变为无锁状态,然后重新偏向新的线程;存活则锁升级到轻量级锁。
2.在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。如果,完成自旋策略还是发现线程没有释放锁,或者让别的线程占用,则线程试图将轻量级锁升级为重量级锁。
ps:升级为重量级锁有两种情况,
一个是自旋到了一定次数;
另一个是第一个线程上锁,第二个线程自旋,第三个线程来的时候就转化为重量级锁。
3.自旋的线程由用户态切换到内核态进行阻塞,等待之前线程执行完成,内核唤醒等待在这个锁上的线程进行竞争。
有一个大佬博主画了十分详细的流程图,
还有《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》中的简化版流程图:
3.其他锁机制
锁剔除:一定不会发生线程安全问题,其他线程不会操作数据。——不需要锁
锁粗化:对同一个对象反复加锁、释放锁。——将锁的范围扩展到整个操作序列外部。
3.CAS操作
前面大概讲解了CAS操作的实现机制,我们发现通过比较-交换就很好地实现了原子化操作,那不用锁机制,直接使用CAS操作不就可以了吗?
循环CAS操作:每次先取出旧值,然后比较取出的值和当前内存的值是否一致,一致操作;不一致循环检测。
而且通过CAS比较交换可以扩展实现一群原子化操作:
1.自增/自减 :新值变为旧值+1 / 旧值-1
2.交换:新值变为要交换的值。
3.也可以不相等则……
但是cas真的可以这么完美吗?
cas存在的问题:
1.ABA:CAS根本在于想要查看值是否发生了变化来决定是否执行这次操作。检查变化时直接用比较实现的。可是值仍旧相等并不代表没有发生过变化:当当前线程比较之前,有一个线程将这个值改回去了,然后当前这个线程以为这个值没有修改过就继续执行了后续操作。
解决这问题的方法:是为每一次操作引入一个version号,操作一次++,以此来区别不同操作。
2.循环检测CAS是否可以执行,如果一直不能实现操作就会造成CPU资源开销浪费。
3.一次循环是检测一个共享变量的,如果是多个共享变量就难以支持。【时间拉的太长?】
目前各类语言提供的线程安全的变量:对其增减都是原子化的:
std::atomic【c++】
AtomicInteger【java】
4.volatile关键字
并发编程的3大特性
1)原子性:某些操作必须是一个整体,不可以被打断。
2)可见性:一个线程改变了值要保证其他线程都可以看见(知晓)。
3)有序性:优化进行指令重排,对于单线程是串行,对于多线程就变得无序。
1.volatile原理
volatile实现的就是可见性和有序性。
实现机制
主线程有一个主存,其他线程都将主存中的变量拷贝一份到本地线程,互不干扰,当有一个线程修改volatile修饰的变量时,就会将该值强制刷新到主存,并导致其他线程的缓存无效。当其他线程想要使用该变量时,发现值无效就从主存中重新获取最新变量。
实现的底层原理
volatile作为关键字修饰变量。在这个变量的读写操作前后,加一些屏障控制,可以实现:
当volatile变量被读取前,读取操作执行完毕;这个volatile变量读取操作完成之后才可以进行其它读写操作;
这次写操作执行之前的写操作都被线程看到;当前写操作之后的所有读操作都看到了这次写过程。
在jvm上实现了4种屏障:
LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
硬件层面提供的屏障
sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见
lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
mfence:即全能屏障(modify/mix Barrier ),兼具sfence和lfence的功能
lock 前缀:lock不是内存屏障,而是一种锁。执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。
volatile的屏障:
LoadLoadBarrier
volatile 读操作
LoadStoreBarrier
StoreStoreBarrier
volatile 写操作
StoreLoadBarrier
源码——jdk8:写操作
在底层源码实现的时候,可以看到首先判断了这个变量的类型是否为volatile,如果是则在赋值操作前后添加屏障,否则就直接赋值。
2.volatile实现了什么
上面已经讲了volatile实现的是可见性和有序性,通过屏障机制严格限制某些指令执行的顺序,并且在变量发生变化之后及时使其他线程本地变量无效,实现了可见性。
可是他并不能保证原子性操作,即获取——修改——写回之间还是可以被打断的,因此无法保证线程安全。
详细分析:
还是两个线程A、B想要实现对主存中的num++,num被volatile修饰。
A、B均保存了当前变量到本地线程,当线程A加载 num到寄存器中,时间片用完被打断,此时还没有写回到本地变量,没有触发写屏障;
线程B 加载 num到寄存器实现++,并将其写回到本地变量,然后触发写屏障,强制刷新到主存;
回到线程A, 这时虽然之前B线程触发了写屏障,但是A线程在此之前已经load了num到寄存器,没有对其的访问了,因此并不会再次从主存中获取最新数据,而是将寄存器的数据++之后写回到变量,也触发写屏障,强制写回。
因此线程仍旧不安全。
大佬文章:
分析锁原理以及变化路径↓
讲CAS操作 & CAS如何实现线程安全↓
volatile底层原理 扒了源码 & 详细分析了volatile为啥还会造成线程不安全↓
为什么volatile也无法保证线程安全_IT_农厂的博客-CSDN博客_volatile为什么不能保证线程安全
volatile关键字无法保证线程安全的讨论_Simon铭少的博客-CSDN博客_volatile关键字不能保证线程安全
哦哦哦现在应该对于线程安全这个问题有了一定程度的了解了,有很多细节还需要扣扣源码和细节,
例如
原子化关键字操作是否要配合CAS才能实现线程安全?
原子化关键字是如何实现线程安全的?
……
共勉呀~