目录
线程安全的定义
关于线程安全,首先我们要理解什么是线程不安全。看这样一段代码:
public class Tset {
static long n = 0;
static final long COUNT = 1_0000_0000L;
static class Add extends Thread {
@Override
public void run() {
for (long i = 0; i < COUNT; i++) {
n++;
}
}
}
static class Sub extends Thread {
@Override
public void run() {
for (long i = 0; i < COUNT; i++) {
n--;
}
}
}
public static void main(String[] args) throws InterruptedException {
Add add = new Add();
Sub sub = new Sub();
add.start();
sub.start();
add.join();
sub.join();
System.out.println(n);
}
}
有两个线程,一个线程执行一亿次n++的过程,一个线程执行n--的过程。按照正常思考,结果应该是0,结果出现了如下的结果,并且每次运行都是变化的。随机性的根源来自于线程调度上的一些随机因素,比如谁从CPU上切下来是随机的,谁被选中去CPU上工作也是随机的。
这就是由于线程不安全导致的。我们会发现,里面有一个共享变量n,并且两个线程都对这个变量进行了操作。我们可以得出一个结论:
什么是线程不安全?可以这样理解
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。即多线程在程序的运行结果可以保证100%的正确性,才能说是线程安全的。
线程不安全的两个条件?
1. 多线程之间有共享资源
2. 多线程之间有针对共享资源的修改操作
由此也可以推断出,当然存在天生安全的线程。例如,没有共享资源,或者多线程之间不能对共享资源进行修改。这样的代码就是天生线程安全的。
线程不安全的原因
那么了解了什么是线程不安全,线程不安全的两个条件。我们要细细品一下为啥线程它就不安全呢,即线程不安全的原因。主要分为三个方面。
1. 原子性
原子在化学中的定义是不可再分的,那么破坏了原子性就使得一条代码之间的执行分开了。怎么理解呢:
其实 一条 java 语句不一定是原子的,也不一定只是一条指令
从这我们就可以理解上面最开始代码计算结果出现随机值的原因了。可能在n++执行那三步其中一步是因为各种原因从CPU上调度下来了。n--被调度上去了。执行的结果就不是我们预期的结果了,当数据量非常大的时候,体现的就会比较明显。
因此我们可以得出:
不保证原子性会给多线程带来什么问题?
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。
如何解决破坏原子性问题呢?
加锁机制。n++执行的时候,先加一把锁,执行完再把锁释放。这样在n++执行的时候 释放锁之前n--就不会执行了。有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
2. 内存可见性
在CPU中有一个高速缓存,从CPU缓存中读写数据速度远远大于从内存中读写数据。因此减少了对内存的读写,提升了代码的运行速度。但是会出现一个问题:当我们执行一个代码所出现的中间值全部都保留在自己的告诉缓存中,而CPU之间共享的数据都是内存的。如果有多个CPU的话,当前计算的结果,其他CPU上正在执行的代码是看不到的。
因为这套机制带来的问题就是内存的可见性问题。
不保证内存可见性会给多线程带来什么问题?
无法保证内存中的数据 各个CPU看到的是一致的,导致计算错误。
如何解决破坏内存可见性问题呢?
JVM在运行线程代码的时候,会把线程代码分为两部分:1. 工作内存 2. 主内存 通过一些指令解决内存的可见性问题
1. 通过load把数据从主内存加载到工作内存
2. 接下来所有的操作都在工作内存中完成,线程不能直接操作主内存上的数据
3. 计算结束后,选择合适的时机再把工作内存上的数据save(刷新)回主内存
操作1保证了工作内存执行的数据都是从主内存取得的值,操作3保证了主内存的数据一定是最新的。
3. 代码顺序性
代码的重排序就是:指令最终的执行顺序不是代码的书写顺序。
由上面的例子可以看出来:代码重排序可能会提高代码的执行效率,因为往往我们写的代码顺序不是最优解。 因此:
1. 编译器有可能进行重排序
2. 运行阶段,JVM也有可能进行重排序
3. 运行阶段,CPU指令上也会进行一定的重排序
使用重排序会给多线程带来什么问题?
可能会导致程序执行的结果不正确了。
如何解决重排序问题呢?
Java原生规定了一些行为必须在另一些行为之前,这个顺序是不能被重排序的
还提供了一些机制,限制了重排序的自由度
可以得出一个很重要的结论:并发编程中,设计中要减少资源的共享,只有没有资源共享,线程天然就是安全的。如果有共享,尽量共享那些不被修改的资源,只要共享资源不被修改,天然就是线程安全的。如果有共享资源,并且共享资源可以被修改,才考虑下面的一些机制尽量保证线程安全。
解决线程不安全的一些机制
机制1 加锁机制
synchronized 关键字-监视器锁monitor lock。一把锁直线两个线程之间互斥。
1. 基本语法
可以作为修饰方法的修饰符,写在定义方法之前。
//修饰普通方法
synchronized void func() {
}
//修饰静态方法
synchronized static void func() {
}
synchronized 修饰普通方法,争夺的是this引用指向的对象中的锁。
synchronized 修饰静态方法,争夺的是 类.class 引用指向的对象中的锁。
作为代码块出现
void func() {
Object o = new Object();
synchronized (o) {
}
}
synchronized 代码块中,锁的是引用指向的对象中的锁。
2. 抢锁过程
A线程加锁,线程 A 执行它的代码前 会插入一个字节码 lock,执行结束会插入一个字节码 unlock。lock 和 unlock之间是不会被中断的。
如果 B 线程lock锁,锁现在是锁上的状态,所以 B 无法 lock 到锁。 B 抢锁失败,会造成如下后果:
1. JVM会强制 B 让出 CPU
2. 状态由 Runnable -> Blocked,即从就绪队列移到阻塞队列,B再也没有资格抢CPU
随着 A 的unlock:
1. 把锁释放掉
2. 把当时抢这把锁失败的线程从Blocked -> Runnable,即从阻塞队列移到就绪队列。重新拥有抢锁的资格了
但是大概率还是会被A抢到锁,因为A还在CPU上。当然,这就是概率性事件了。
3. 锁的互斥
锁的互斥是比较容易理解的,主要看两个方面:1. 看他们是不是在争夺锁 2. 争夺的是不是同一把锁 。满足条件那么就是锁的互斥。
4. synchronizad解决的问题
1. 保证原子性
有线程互斥的情况下,保证了lock 到 unlock结束的这段指令,不会被其他互斥线程中断。
2. 在互斥的线程之间,适度的保证可见性
synchronizad释放锁的时候,强制把当前锁持有线程的工作内存刷新到主内存中;synchronizad请求成功锁的时候,强 制把当前锁持有线程的工作内存清空掉,要求重新从主内存中读取。但是只保证一头一尾,中间不确定。
3. 可以适度的影响到重排序
组内代码可以重排序,组间代码顺序不能乱
机制2 volatile机制
volatile主要是属性和静态属性的修饰符。
作用1:被 volatile修饰的属性,无论是什么类型的,都是原子的。(主要是long 和 double)。即保证了 变量 = 常数这种赋值的原子性。
作用2:被volatile修饰的变量,具有可见性
作用3:保证了 引用 = new 构造方法(); 的重排序的正确性
机制3 通信对象的等待集
1. 作用
wait() 和 notify(notifyAll) 这些方法是属于Object的方法,即是所有类都具有的。
Object o = new Object();
当 A 线程调用 o.wait() 之后,A线程会放弃CPU,并且失去抢夺CPU的资格(Runnable - > Waiting),A 线程从就绪队列被移到o指向对象的等待集(wait set)。A线程开始等,等到有线程唤醒他。
当b线程调用 o.notify() 之后,线程本身没有什么变化,依然在CPU上继续运行,但是会把o指向对象的等待集上任意一个线程从等待集上移动到到就绪队列中,并且把这个线程的状态变更(Waiting -> Runnable),使得一个线程重新拥有了抢CPU资格。B线程唤醒了o指向对象等待集上的一个线程。
当b线程调用 o.notifyll() 之后,会被把o等待集上所有等待的线程都唤醒
2. 语法要求
因为wait()/notify()/notifyAll事实上都会修改o指向对象的等待集,为了让这个过程保持原子性。Java规定:调用时,必须请求o这个对象上的锁。 即wait() 调用过程中,首先会unlock这个锁,当被唤醒时,需要重写lock这个锁。
synchronized (o) {
o.wait();
}
以上三种方法可以尽量保证线程安全。不能保证线程的绝对安全。