多线程编程中,最让人头疼的问题莫过于线程安全,如果对存在线程安全问题的代码不加以处理,可能会带来严重的后果,例如用两个线程对同一个变量进行增加操作
class Counter {
//这个 变量 是两个线程要去自增的变量
public int count;
public void increase() {
count++;
}
}
public class Demo15 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i = 0; i < 50000; ++i) {
counter.increase();
}
});
t1.start();
Thread t2 = new Thread(()->{
for(int i = 0; i < 50000; ++i) {
counter.increase();
}
});
t2.start();
//等待t1和t2执行完,再打印count的结果
t1.join();
t2.join();
//在main中打印一下两个线程自增完成后,得到的count结果
System.out.println(counter.count);
//如果不加锁,极端情况
//所有的操作都是串行的,最终结果就是10w(可能出现,极小概率事件)
//所有操作都是交错的(并行),最终结果就是5w(可能出现,极小概率事件)
}
}
预期结果为10w,但实际结果却为67627,这就是线程安全问题导致的。
产生线程不安全的原因有很多:
- 线程是抢占式执行的,线程之间的调度充满随机性(线程不安全的万恶之源,但是我们无可奈何)
- 多个线程对同一个变量进行修改操作(如果多个线程针对不同的变量进行修改,没事。如果多个线程针对同一个变量读,也没事),上诉代码线程不安全就是这个原因导致的
- 针对变量的操作不是原子的(针对有些操作,比如读取变量的值,只是对应的一条机器指令,此时这样的操作本身就可以视为原子的。通过加锁操作,也可以把好几条指令打包成原子的)
- 内存可见性也会影响到线程安全。例如针对同一个变量,线程A进行循环读取,但循环内部并不修改,此时编译器就会优化,把这个变量从内存保存到寄存器中,每次都读取寄存器中的内容(这样做是为了提高效率,因为寄存器的读写效率高于内存),此时线程B修改了这个变量(从内存中读到CPU上,修改完毕后,再写回内存),但不会影响线程A,因为线程A并没有从内存中读取这个变量。在Java中,内存可见性用关键字volatile保证,但它不保证原子性
- 指令重排序也会影响到线程安全,不了解的可以看我之前写的博客指令重排问题。大部分代码,彼此的顺序,谁在前,谁在后,无所谓,些代码却依赖前后关系,编译器就会智能的调整代码的前后顺序,从而提高程序的效率,但是应该保证逻辑不变的情况下,再去调整顺序。如果代码是单线程的程序,编译器的判定一般都是很准的,但是如果代码是多线程,编译器也可能存在误判。 volatile也能防止指令重排序
上述介绍的这5种情况,都是产生线程不安全的原因
对此我们应该对increase()方法加锁或者count这个变量加锁,在C++中需要创建mutex变量,再加锁,然后再解锁,个人觉得有点麻烦(C++加锁的方式有很多)。在Java中,加锁的方式也有很多,最简单,最常用的方式就是在increase()方法最前面加上synchronized关键字,将整个方法锁住,这样就解决了线程安全问题
class Counter {
//这个 变量 是两个线程要去自增的变量
public int count;
synchronized public void increase() {
count++;
}
}
synchronized 会自动加锁,本质是修改了Object对象中的"对象头"里面的一个标记
synchronized是个可重入锁,可重入锁内部会记录当前的锁被哪个线程占用,同时也会记录一个"加锁次数(引用计数)"。当锁的计数减到0后,就解锁,可重入锁的意义就是降低了使用成本,提高了开发效率,但是也带来了更大的开销(维护锁属于哪个线程,并增加了计数,降低了运行效率)
synchronized 的使用方法
1.直接修饰普通的方法
使用synchronized的时候,本质就是针对某个"对象"进行加锁,此时锁对象就是this
2.直接修饰代码块
需要显示指定哪个对象需要加锁(Java中的任何对象都可以作为锁对象)
3.修饰静态方法(更严谨的叫法应该是"类方法")
相当于针对当前类的类对象加锁
synchronized
- 既是一个乐观锁,也是一个悲观锁(根据锁竞争的激烈程度,自适应)
- 是一个普通的互斥锁
- 既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)
- 轻量级锁的部分基于自旋锁实现,重量级的部分基于挂起等待锁实现
- 非公平锁
- 可重入锁
synchronized几个典型的优化手段(只考虑JDK1.8)
1.锁膨胀/所升级
体现synchronized能够"自适应"这样的能力
代码还能执行到synchronized部分,此时处于无锁状态
当首个线程执行到了synchronized部分,此时就会进入偏向锁状态,偏向锁只是做了一个标记,并没有真的加锁,这样带来的好处就是后续如果没有线程竞争,就避免了加锁,解锁带来的开销
如果此时又有其他线程执行到了synchronized部分,产生锁竞争,此时进入**轻量级锁(自旋锁)状态
如果竞争进一步加剧,就会进入重量级锁(互斥锁)**状态
无锁——>偏向锁——>轻量级锁(自旋锁)——>重量级锁(互斥锁)
2.锁粗化/细化
此处的粗细是指"锁的粒度"
"锁的粒度"是指加锁的代码涉及到的范围
加锁的代码范围越大,认为锁的粒度越粗
加锁的代码范围越小,认为锁的粒度越细
到底锁的粒度是粗好,还是细好?各有各的好
如果锁的粒度比较细,多个线程之间的并发性就更高
如果锁的粒度比较粗,加锁解锁的开销就更小
Java编译器就会有一个优化,会自动判定(一般来说编译器优化后,效率会变高,但也有意外情况)
如果两次加锁之间的间隔较大(中间隔的代码多),会细化(一般不会进行这种优化)
如果两次加锁之间的间隔较小(中间隔的代码少),会粗化(很可能触发这个优化)
3.锁消除
有些代码,明明不用加锁,结果你给上锁了,编译器就会发现这个加锁操作好像没什么必要,就直接把锁给去掉了
例如给单线程进行加锁,这个时候编译器就会进行锁消除,
单线程中使用到了StringBuffer,Vector等,它们都是在标准库中进行的加锁操作,实际使用的时候可能存在锁消除