线程安全性
如果一个类在单线程环境下能够运行正常,并且在多线程环境下不用修改代码也能正常运行,那么就称它为线程安全
的。反之,在单线程下正常运行的类在多线程下不能正常运行则称为线程不安全
,造成线程不安全的原因有很多。
举个例子:
public class Demo implements Runnable{
int ticket = 100;
@Override
public void run() {
while (true) {
if (ticket > 0) {
ticket--;
System.out.println(Thread.currentThread().getName() + "抢到了第" + ticket + "张票");
}
}
}
public static void main(String[] args) throws InterruptedException {
Demo demo = new Demo();
for (int i = 0; i < 10; i++) {
new Thread(demo).start();
}
}
}
部分运行结果:
Thread-0抢到了第98张票
Thread-3抢到了第96张票
Thread-2抢到了第97张票
Thread-1抢到了第98张票
Thread-2抢到了第92张票
......
运行结果中Thread-0和Thread-1都抢到了第98张票,这就是线程不安全的表现。
上下文切换
- 出现的原因:多线程共享单处理器时要实现并发使用的是
时间片分配机制
实现的,时间片决定了一个线程可以连续占用处理器运行的时间长度
,当一个线程由于时间片使用完
或被迫或者主动暂停运行
时,操作系统会保存该线程的“执行进度”称为上下文信息
,接着线程调度器会选中下一个线程开始或者继续执行。这一过程被称为上下文切换。当下一次被分配执行时会读取保存的上下文信息继续执行,可见看似连续执行的线程实际上是经过切换断断续续执行的。 - 从Java应用的角度来看,线程在就绪状态(Runnable)或者运行状态(Running)与阻塞(Blocked,Waiting和Time_Waiting)状态进行切换的过程就是一个上下文切换的过程。
- 具体诱因:
①自发性上下文切换
线程在运行状态下执行sleep,wait,yield(不一定),join,park(已停用)任一方法时都会引起自发性上下文切换。线程发起I/O操作或者等待其他线程持有的锁时也会导致自发的上下文切换。
②非自发性上下文切换
线程由于线程调度器的原因被切出。例如时间片用完,或者有优先级更高的线程需要被运行。另外JVM执行垃圾回收时可能需要暂停所有线程,这也会导致非自发性上下文切换。 上下文切换的开销
①直接开销:
操作系统恢复上下文的开销,线程调度器进行线程调度的开销
②间接开销:
处理器高速缓存重新加载的开销,因为线程可能被分配到另一个没有执行该线程的CPU上,此时需要将访问的变量从内存或者通过缓存一致性同步到此处理器的缓存中。
原子性
概念
:对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割
的,那么该操作就是原子操作,称该操作具有原子性。
重点在于不可分割,指的是某个共享变量的操作对于其执行线程以外的任何线程来看,该操作要么已经执行,要么尚未发生,其他线程不会得到该操作执行的中间效果。
例1
//该操作是不可分割的具有原子性
a = 0
//下列操作不具有原子性,它是可分割的,a++的操作分为
//读取变量a的值,将a+1,再赋值给变量a
//其中如果有其他线程对a的数据进行了变更都会出现不希望出现的结果
a++
例2
如下代码,假设线程A调用了getHostInfo来获取ip和端口,在执行到getIp()时CPU时间片用完,此时另一个线程B调用了updateHostInfo,更改了ip和端口,接着CPU继续执行线程A的getPort()方法,此时返回的HostInfo是旧的ip和新更改的端口,造成了脏读。
String ip;
String port;
//省略get/set
public void updateHostInfo(){
//不具有原子性
setIP();
setPort();
}
public void getHostInfo(){
String ip = getIp();
String port = getPort();
return ...;
}
可见性
概念:在多线程环境下,一个线程对共享变量更新后,后序访问该变量的线程可能无法立刻读取到更新结果,这就是线程安全问题的另一个表现形式:可见性。
例1
public class Demo implements Runnable {
static boolean cancel = true;
@Override
public void run() {
while (cancel) {
}
System.out.println(Thread.currentThread().getName() + "-end");
}
public static void main(String[] args) throws InterruptedException {
Demo demo = new Demo();
Thread t1 = new Thread(demo);
t1.start();
Thread.sleep(2000);
cancel = false;
System.out.println(Thread.currentThread().getName() + "-set cancel to false");
}
}
运行结果:等待两秒后打印了如下语句,然后出现了死循环。原因在于main函数中修改了cancel = false但是线程t1中并没有读取到。
main-set cancel to false
具体的原因有两个:
- 由于没有给JIT编译器足够的提示,使得它认为状态变量cancel只有一个线程对其访问,JIT编译器为了避免重复读取状态变量cancel以提升效率,将
while (cancel) {
}
优化成了等效的机器码:
if(cancel){
while(true){
}
}
这种优化导致了死循环。
- 另外一个原因与计算机的存储系统有关。
CPU的处理速度远大于内存的读写速度,如果CPU直接操作内存,在存取的过程中CPU将会处于空闲状态,这将会是极大的浪费。所以CPU中有寄存器,多级高速缓存,写缓冲器等部件执行内存的读写操作。每个CPU都有各自的寄存器和多级缓存。
1.如果两个线程分别运行在不同的CPU上,而且两个线程的共享变量被分配到寄存器上存储,一个CPU又无法读取另外一个CPU上寄存器的内容,那么可见性问题就产生了
2.即便共享变量在主内存中存储,也不能保证该变量的可变性,CPU不直接操作内存,而是通过操作高速缓存再定期同步到内存中的。一个CPU上的线程对共享变量进行了更新可能是只更新到了该CPU的写缓冲区中的,还没有同步到高速缓存更别提同步到主存。一个CPU不能读取另一个CPU的高速缓存和写缓冲区
有序性
概念:
有序性指的是一个处理器上运行的线程所执行的内存访问操作在另外一个处理器上运行的线程看来是乱序的。出现有序性问题的原因在于重排序。
重排序
重排序是对内存访问有关的操作所做的一种优化,它可以在不影响单线程程序正确性的情况下提升性能,但是它可能在多线程中对程序的正确性产生影响。
CAS
- compare and swap的缩写,翻译过来是比较并转换。
- CAS算法主要是提供了原子性的操作方法,保证一个共享变量的线程安全。
- CAS操作包含三个操作数:
①内存位置V
②预期原值A
③新值B
如果内存位置V的值与预期原值A相匹配,那么将会把内存位置的值更新为新值B,否则将不会进行更新。 - 举个例子:
Thread-1和Thread-2都要对变量E进行修改假设E=2
①Thread-1使用CAS算法进行修改,首先获取到E的内存位置,V指针指向它,获得E的值赋值给A,CPU时间片用完,触发上下文切换
②Thread-2一系列操作将E的值改成了3
③Thread-1继续执行,将内存位置V的值与预期原值A进行对比,显然此时3和2不相等,不会进行更新。 - 导致的问题:
①ABA问题
接上面的例子,如果Thread-2将E的值从2改成了3然后又改回了2,那么Thread-1中读取的值没有变化,将会继续进行更新,这种情况在有些场景下会导致问题
。ABA问题也有可能发生在地址重用
,CAS是根据内存地址去获取值的,如果一个内存分配后释放了,新对象的分配很有可能会使用有原来的地址。
例如:有一个单项链表实现的栈,现有的栈顶节点为A,结构为A —> B。
a. 此时Thread-1使用CAS算法开始操作节点A,获得了内存位置和预期原值后,出现了上下文切换。
b. Thread-2将A和B出栈后又将DEA三个节点入栈,此时的结构为 A —> E —> D。
c. Thread-1继续执行,此时它对比内存位置和期望原值相等,并进行了后序的操作。Thread-1认为A的后继节点是B,但是在Thread-2操作以后,A的后继节点已经变成了E。
如何解决ABA问题?
在JUC包的原子类中使用的解决方案是使用版本号来解决ABA问题,即每次进行数据修改的操作后会更新版本号,在进行操作时一旦版本号和数据的版本号不一致则不执行修改。
②循环时间长开销大
自旋CAS(执行不成功的话就会一直循环执行到成功),如果长时间不成功,会被CPU带来较大的开销
③只能保证一个共享变量的原子操作
锁
乐观锁/悲观锁
乐观锁:
总是乐观的认为每次拿数据时别人不会修改,所以乐观锁在读操作不会上锁,但是在更新时会判断一下更新期间有没有被人抢先更新异步,可以用版本号机制和CAS算法实现。乐观锁适用于读比较多的场景
,相对于悲观锁可以提高较大的吞吐量。但是在多写的情况下容易出现大量冲突,这时就会导致不停的retry,反而是降低了性能。悲观锁:
顾名思义,就是很悲观的认为每次拿数据和写数据时都会被别人修改,所以在每次读写数据时都会上锁,这样别人想要修改这个数据就得阻塞到拿到锁为止,换句话说共享资源每次只能有一个线程对其进行操作,其他线程阻塞,直到用完以后其他线程才能获取。悲观锁一般用于写操作较多的情况。MySQL中的行锁,表锁,读锁,写锁等都是悲观锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
独占锁/共享锁
-
独占锁
独占锁
又称排他锁
或互斥锁
,该锁每次只能被一个线程持有,后序获取锁的线程会被暂时阻塞。 -
共享锁
共享锁又称为读锁,可以查看但是无法修改和删除的一种数据锁。当数据被加上读锁后,其他线程可以对其并发读取数据,但是不能在获取的数据上加排它锁,直到所有共享锁都释放。
公平锁/非公平锁
公平锁
公平锁是指多个线程获取锁时按照申请锁的时间顺序公平的来获取锁
-非公平锁
非公平锁指的是多个线程获取锁的顺序不是按照申请锁的顺序,有可能后申请的线程比先申请的线程先获取到锁,有可能造成优先级反转或者饥饿现象。
非公平锁的吞吐量比公平锁大
分段锁
分段锁并不是具体的一种锁,而是一种锁的设计,通过分段锁可以降低锁的竞争从而较少了上下文切换,提高性能。例如并发容器类ConcurrentHashMap
的加锁机制就是基于粒度更小的分段锁
,它使用了一个包含16个锁的数组,每个锁保护散列桶的1/16,如果数据分布合理,那么可以使对锁的竞争可以减少到原来的1/16。
自旋锁
自旋锁是指一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将会循环等待,然后不断判断锁是否能够被成功获取,直到获取到锁或者达到限制才会退出循环。通常是为了减少进去阻塞而导致的上下文切换
,Java中的自旋锁是有次数限制的,在自旋一定时间后没有获取到锁就会进入阻塞。在CAS中也有自旋锁的使用。
偏向锁 / 自旋锁/轻量级锁 / 重量级锁
synchronized锁的状态是通过Monitor对象的字段来表明的,这三种锁表示的是锁的状态而不是具体的锁类型,jvm提供者三种状态是为了提高锁的获取与释放效率
。
锁状态转换顺序为:
(锁只能升级,不能降级)
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
①偏向锁 - 偏向锁是当一段同步代码一直只有一个线程访问时,那么JVM编译代码解释执行的时候,会自动放弃同步信息,使用
锁标记
的形式记录锁状态,在Monitor对象中有变量 ACC_SYNCHRONIZED,当变量使用时,偏向锁锁定,可以避免锁的争抢和锁池状态的维护
。
②轻量级锁
- 当偏向锁不满足时,也就是有多线程并发访问时,先提升为轻量级锁,也是使用变量 ACC_SYNCHRONIZED标记记录未获取到锁信息的线程,也就是两个线程时优先使用轻量级锁,但是也可能出现重量级锁。
另一个线程会通过自旋的方式尝试获取锁,不会阻塞,提高性能
。
③当请求锁的线程更多以后,就会升级到重量级锁,在重量级锁中,获取锁的线程也会自旋,但是在自旋一定次数后还没有获取到锁就会进入阻塞。