前言
在你立足处深挖下去,就会有泉水涌出!别管蒙昧者们叫嚷:“下边永远是地狱!”
博客主页:KC老衲爱尼姑的博客主页
博主的github,平常所写代码皆在于此
共勉:talk is cheap, show me the code
作者是爪哇岛的新手,水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!
文章目录
- 共享受限资源
- 什么是线程安全问题?
- 存在线程安全问题
- 线程不安全的原因
- 原子性
- 内存可见性
- 指令重排序
- synchronized的特性
- 1. 互斥
- 2. 可重入
- synchronized使用示例
- 1. 直接修饰普通方法 锁的 Counter对象
- 2. 修饰静态方法: 锁的 Counter类的对象
- 3. 修饰代码块:明确指定锁哪个对象
- volatile
- volatile与synchronized
共享受限资源
在单线程序中,只有一个线程在干活。因此不会存在多个线程试图同时使用同一个资源。这就好比,不允许两个人在同一个停车位停车,两个人同时使用一个坑位,甚至是两个人坐在公交车上的同一个位置。并发虽然能同时做多个事情,但是,多个线程彼此可能互相干涉。如果无法避免这种冲突,就可能会发生两个线程同时修改同一个变量,两个线程同时修改同一个支付宝账户,改变同一个值等诸如此类的问题。
什么是线程安全问题?
操作系统中的线程调度采取的是抢占式执行,多个线程的调度执行过程,可以视为"随机的",而这些线程可能会同时运行某段代码。程序每次运行的结果和单线程运行的结果是一样的,而且其他的变量和预期的也是一样的,就是线程安全的,反之就是线程不安全。
存在线程安全问题
考虑下面一个例子,两个线程对同一个变量自增,使得这个变量的值得到10000.
示例代码
public class Counter {
private int count = 0;
public void increase() {
count++;
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行结果:
很遗憾并没有达到我们的预期结果,之所以会这样是因为count++操作不是原子的,具体什么原子性以及如何解决,请看下文。
线程不安全的原因
- 多个线程同时修改同一个共享数据,如上述代码修改堆上的count
- 操作系统对于线程的调度是抢占式的
- 修改操作不是原子的
- 内存可见性问题
- 指令重排序
原子性
原子操作是不能被线程调度机制中断的操作;一旦操作开始,那么它一定可以在可能发生的“上下文切换”之前(切换到其他线程执行)执行完毕。一句话就是要么不做,做的话就是一次性做完。
内存可见性
可见性指,一个线程对共享变量值 的修改,能够及时地被其他线程看到。在Java虚拟机中定义了Java内存模型,其目的就是屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
-
线程之间的共享变量存在 主内存 (Main Memory),实际上是内存。
-
每一个线程都有自己的 “工作内存” (Working Memory) ,这里的内存指的是CPU中 的寄存器或者高速缓存。
-
当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.。
-
当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存。
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化。
初始情况:初始情况下, 两个线程的工作内存内容一致
线程1将空间中的a修改为25,线程1中的值不一定能及时同步到主内存中,对应的线程2的工作内促的值也不一定能及时同步。
为啥要这么麻烦的拷贝?
因为CPU访问自身的寄存器以及高速缓存的速度,远远超过访问内存的速度(快了3-4个数量级),也就是几千倍,上万倍。
指令重排序
编译器在逻辑等价的前提下,调整代码的执行步骤来提高程序的运行效率。就像某一天你打算先去菜鸟拿U盘,然后回宿舍写作业,然后再和朋友一起去拿快递。这个事情就可以优化成先写作业,然后和朋友一起去菜鸟,顺便把U盘拿了。这样就可以少跑一次菜鸟,这就叫指令重排序。
案例分析
上述代码利用两个线程将一个变量从0自增到10000,但是实际值是小于10000。其原因是因为 线程调度是随机的,造成了线程自增操作的指令集交叉,从而导致实际值小于预期值,至于为啥会造成指令集交叉又因为count++这个操作不是原子的,不是原子意味着不是一气呵成的,而是由三步操作完成:
- 从内存把数据读到CPU中的寄存器,该操作记作load
- 对数据完成自增,.该操作记作add
- 把数据写会内存,该操作记作save
对一个数进行两次自增操作,初始值为0,目标值为2,两个线程并发执行,进行2次子自增。具体线程间指令集可能出现的情况如下:
情况1:线程之间指令集没有任何的交叉,实际值等于预期值。具体如下图所示
情况2:线程之间指令集存在交叉,实际值小于预期值。具体如下图所示
根据上面的分析可知,上述代码出现线程不安全的问题是线程的抢占式执行以及count++操作不是原子性的,由于线程调度是由操作系统所决定,我们无从干涉。那么就只能将不是原子性的操作打包成一个原子性的操作,这样无论线程如何随机的调度,都不会出现bug,至于如何打包,就得通过加锁来解决。
线程加锁
上述的案例告诉了我们一个使用线程的基本问题:你永远不知道一个线程什么时候运行,什么时候不运行。想象一下,你正在吃饭,当你拿筷子夹肉的时候,突然肉就消失不见了,因为你的线程被挂起了,而另一个人在你挂起的期间把那块肉吃了。对于并发工作,我们需要某种方式来防止两个任务同时访问相同的资源。解决这个冲突的方法就是当资源被一个任务使用时,在其上加锁,第一个访问的某项资源的任务必须锁定这个资源,使得其他的任务在被解锁之前,就无法访问它,而解锁之时,另一个任务就会锁定并使用它,以此类推。如果浴室是共享的受限资源,当你冲进去的时候,把门一关获取上锁,其他的人要使用浴室就只能被阻挡,所以就得在浴室门口等待,直到你使用完为止。在Java中提供了synchronized的形式,为防止资源冲突提供了内置支持。当任务要被执行synchronized关键字保护的代码片段时候,它将检查锁是否可用,然后获取锁,执行代码,释放锁。
synchronized的特性
1. 互斥
synchronized会起到互斥的效果,某个线程执行到某个对象的synchronized中,其他线程如果也执行到同一个对象的synchronized所包含的代码中就会阻塞等待。
public void increase() {
synchronized (this) {//进入该代码块,相当于针对当前对象"加锁"
count++;
}//退出该代码块,相当于针对当前对象"解锁"
}
synchronized用的锁是存在Java对象头里面,可以简单的理解为,每个对象在内存中存储时,都会有一块内存表示当前"锁定"的状态,相当于记录有没有人使用,如果当前是"无人"状态,那么就可以使用,使用时需要设为"有人"状态,如果当前是"有人"状态,那么其他人无法使用,只能排队。这个排队并不是真正意义上的按顺序来,在操作系统内部会维护一个等待队列,当这个锁被某个线程占有的时候,其他线程尝试进行加锁,就加不上,就会阻塞等待,一直等待之前占有锁的线程解锁之后,由操作系统唤醒一个新的线程,再来获取锁,唤醒某个线程并不遵守先来后到的规则,比如A和B线程都在等待C线程释放锁,当C线程释放锁之后,虽然A线程先等待,但是A不一定先获取到锁,而是要和B竞争,谁先抢到就是谁的。
2. 可重入
synchronized所包含的代码块对于线程的来说是可重入的,不会出现自己把自己锁死的情况。所谓自己把自己锁死可以理解为针对同一个对象连续加锁多次,按照之前对锁的设定,第二次加锁的时候,就会阻塞等待,知道第一次的锁是释放,才能获取到第二个锁,但是释放第一个锁也是由该线程完成的,导致该线程就彻底躺平了,啥都干不了,就无法进行解锁的操作。这就是死锁。
public void increase() {
synchronized (this) {//加锁
synchronized (this) {//加锁
count++;
}//解锁
}//解锁
}
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
-
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
-
解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
比如上述连续加锁的代码,第一次加锁的时候计数器加一,紧接着第二次又加锁,发现锁的持有者还是自己继续加一,然后就进行两次锁的释放,最终计算器为0时,才是真正的释放锁。
synchronized使用示例
synchronized 是对象锁本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具
体的对象来使用.
1. 直接修饰普通方法 锁的 Counter对象
对上述自增程序尝试使用synchronized加锁,两个线程同时访问的是increase()方法,所以对此方法加锁,实际上对某个对象加锁,该方法属于实例方法此锁的对象就是this。
public class Counter {
private int count = 0;
public synchronized void increase() {
count++;
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行结果:
2. 修饰静态方法: 锁的 Counter类的对象
示例代码
public class Counter {
private static int count = 0;
public static synchronized void increase() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
increase();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
increase();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
运行结果:
3. 修饰代码块:明确指定锁哪个对象
public class Counter {
private int count = 0;
public void increase() {
synchronized (this) {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行结果:
再次分析变量自增的案例
当对increase()方法加锁之后,线程1进入该方法时会尝试着去获取锁,一旦获取到锁就会加锁(lock),当退出方法或者退出synchronized所包含的代码块时会释放锁(lock),在线程1持有锁期间,线程2只能干等着,无法进行自增操作,只能等待线程1释放锁,线程2才会进行自增操作。
注意:两个线程竞争同一把锁才会阻塞等待,如果是获取不同的锁,不会竞争。这就好比,两个男的同时追同一个妹子才会有竞争,否则不存在竞争。
volatile
volatile 修饰的变量, 能够保证 “内存可见性”.
代码在写入volatile修饰的变量的时候
- 改变线程工作内存中volatile变量的副本的值
- 将改边后的副本值从工作内存刷新到主内存
代码在读取volatile修饰的变量的时候
- 从主内存中读取volatile变量的最新值到线程的工作空间
- 从工作空间中读取volatile变量的副本
加上volatile,会强制读写内存,速度是慢了,但是数据的准确性提高了。
示例代码
import java.util.Scanner;
public class Counter2 {
public int flags = 0;
public static void main(String[] args) {
Counter2 counter = new Counter2();
Thread t1 = new Thread(() -> {
while (counter.flags==0) {//该操作对于cpu太快了,所以就直接优化了,第一次读取到了寄存器中后面就没有再从内存中读取
}
System.out.println("循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
counter.flags = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果:
当输入了1线程t1并没有退出,这显然是个bug,给flag加上volatile修饰就可以解决。
public volatile int flags = 0;
运行结果:
volatile与synchronized
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.
示例代码
public class Counter {
private volatile int count = 0;
public void increase() {
count++:
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行结果:
count的值小于预期值,并不能保证原子性。