文章目录
- 一. 由内存可见性引起线程不安全问题的例子
- 二. 分析内存可见性产生的原因
- 三. volatile 关键字(面试题)
- 四. 线程的等待通知机制
- wait
- notify
一. 由内存可见性引起线程不安全问题的例子
public class Demo17 {private static int count = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while(count == 0){; //循环体里啥也没有}System.out.println("t1执行结束");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入一个整数:");count = scanner.nextInt();});t1.start();t2.start();}
}
上述代码中, t1线程做了一个循环, 里面没有内容, 判断条件为count是否为0
t2线程, 通过输入, 修改count的值
我们预期的结果就是, 当输入一个不为0的数字时, t1线程就会结束
那么我们运行的结果:
发现, 线程并没有结束, 这就是bug!!
上述问题产生的原因, 就是"内存可见性"
二. 分析内存可见性产生的原因
这里的条件判断, 在cpu中是两条指令:
- load 从内存中读取数据到cpu寄存器
- cmp 比较, 同时会产生跳转
如果条件成立, 继续执行
如果条件不成立, 就跳转到另外一个地址来执行
当前循环的速度很快, 短时间内出现大量的load 和 cmp反复执行的效果
load的执行消耗的时间, 会比cmp多很多
- 由于上述执行过程中, load从内存读取数据的速度非常慢
执行一次load消耗的时间, 顶上万次cmp消耗的时间 - 另外, JVM还发现, 每次load取到的结果是相同的(在t2修改之前)
于是, JVM干脆就把上述load操作优化
掉了
只是第一次真正的进行load, 后续再执行到对应的代码, 就不在load了, 也就是不再从内存中读取数据了, 而是直接读取刚才已经load过的寄存器的值了
但是优化后, 当t2对count进行修改时, t1就感知不到了
如果在while循环中加入打印操作:
此时就不会出现内存可见性问题
如果循环体内存在IO操作 / 阻塞操作, 这就会使循环的旋转速度大幅度降低了, 此时就没有优化的必要了
所以, 出现上述内存不可见问题, 本质上是编译器优化
引起的
(编译器有且仅有javac, 但是此处优化是javac, java等配合完成工作, 但是统称为编译器)
其实, 编译器到底啥时候优化, 啥时候不优化, 也是一个"玄学问题"
三. volatile 关键字(面试题)
优化, 提高了代码的执行效率, 但是为了效率而承担bug的风险, 也是没必要的
就内存可见性问题来说, 可以通过特殊的方式来控制, 不让编译器触发优化
给变量修饰上这个关键字后, 此时编译器就知道了, 这个变量是"反复无常的", 就不能按照上述策略进行优化了
加上volatile之后, 代码执行的结果为:
上述问题产生的原因, 还可以从JMM(java内存模型的角度来理解)
JMM中是这么表述的:
当t1执行的时候, 要从工作内存中, 读取count值, 而不是从主内存中
后续t2修改count, 也是会修改工作内存, 同步拷贝到主内存, 但是由于t1没有重新读取主内存, 导致最终t1没有感知到t2的修改
这里说的工作内存, 不是我们平时理解的内存, 而应该翻译成"工作存储区", 应该是CPU寄存器 + 缓存
而这里的主内存, Main Memory才是我们理解的内存
四. 线程的等待通知机制
系统内部, 线程是抢占式执行, 随机调度, 程序猿其实是有办法干预的
通过"等待"的方式, 能够让线程一定程度上按照我们预期的顺序来执行
虽然我们无法主动让某个线程被调度, 但是可以主动让某个线程等待, 从而达到更精细的控制线程之间的执行顺序了
因为系统中线程调度是无序的, 很有可能会发生某个线程频繁获取释放锁, 由于获取的太快, 以至于其他线程捞不到CPU的资源, 虽然不会像死锁一样卡死, 但是可能会卡住一下, 这种现象, 叫做==“线程饿死”==
wait
等待通知机制, 就可以解决上述问题
通过条件, 判定看当前逻辑是否能够执行, 如果不能执行, 就主动wait(主动进行阻塞), 就把机会让给别的线程了, 避免该线程进行一些无意义的重试
- wait是Object类提供的方法, 任何一个对象, 都有这个方法
- wait 也会被Interrupt打断, wait和sleep一样, 能够自动清除标记位
- wait内部做的事情不仅仅是阻塞等待, 还要解锁
准确来说, wait解锁额同事, 进行等待
所以得先上锁, 才能谈解锁, 所以wait必须在synchronized内部使用
此时, 通过jconsole工具, 就可以看到线程的状态
wait还有带参数的形式, 表示最长的等待时间, 单位是毫秒, 如果超过时间还没有notify, 就自动唤醒
notify
通过另一线程, 调用notify来唤醒阻塞的线程, 也需要搭配锁使用
当t1进入wait后, 会释放locker锁, 此时t2就可以获取锁
在t2还没有输入之前, t1一直是WAITING状态
输入后, 执行notify, 就会唤醒wait操作, 是t1从阻塞状态回到了RUNNABLE状态
但是t1并不能马上被调度, 因为此时t2还没有释放锁, 意味着t1从WAITING -> RUNNABLE -> BLOCKED
等到t2释放锁以后, t1才能继续执行
执行结果:
注意:
如果此时t2先执行了notify, 但此时t1还没有wait
此时locker上海没有wait, 此时直接notify不会有任何效果(不会抛异常),
但是后续t1进入wait后, 没有别人唤醒了
但是, notify只能唤醒多个等待线程中的一个
上述代码, t1t2都等待解锁, t3通知后, 我们观察结果发现, 只解锁了一个进程
Object类中还有一个方法: notifyAll, 可以解锁所有等待的进程: