看题
前段时间面试遇到一个题目,感觉挺有意思的,特意记录下来。
一般来说,内容是全球变量i
,然后在main方法中有两个嵌套for循环,分别循环100次,然后在循环中打开新的线程对变量i
执行i++操作。我简单修改了一下。
让我们来看看这个话题。如果不运行,用肉眼看,你觉得是什么?给你1分钟的思考...
private static int i = 0;public static void add() { i++;}public static void main(String[] args) { for (int j = 0; j < 100; j++) { new Thread(() -> { for (int k = 0; k < 100; k++) { add(); } }).start(); } System.out.println(i);}
理想情况下,输出应该是1万。但是,以我们多年的做题经验,即使不知道是多少,也绝对不是1万,明显有坑让我们跳。
如果学生的基础更好,他们应该能够猜测输出必须小于或等于1万。那是多少,这个问题调查了什么知识点?
问题1:线程未完成输出也许有些学生一眼就看出这是一个修改共享变量的问题,然后急于回答,然后你就错了。
在第一个坑中,上述代码在循环中打开了许多线程执行add,但您不能保证在主线程输出之前,循环中的线程已经完成。
所以第一步要解决的是等子线程完成,主线程再输出。如果你不做这一步,就不要谈下面的问题。
修改上述代码:
private static int i = 0;public static void add() { i++;}public static void main(String[] args) { for (int j = 0; j < 100; j++) { new Thread(() -> { for (int k = 0; k < 100; k++) { add(); } }).start(); } // 确保子线程完成 while (Thread.activeCount() > 2) { Thread.yield(); } System.out.println(i);}
问题二:内存可见性JMM内存模型分为主存和工作内存,变量i
在主存中,每个新开启的线程都有自己的工作内存,每个工作线程都有主存变量的副本。
例如,在线程A获得变量并执行后,将i
的值2
同步到主存之前;线程B获得i
执行add操作的值;此时,线程a将新值2
同步到主存;线程B还执行了add操作,同样的新值2
与主存同步。
本来主存在这个时候i
值应该是3,但是少了。
当然,这只是一个随机的例子,有很多场景会导致自我增加的损失。
学过JMM的人都知道,Java中提供了一个关键字volatile,它可以保证线程的可见性,我们可以给变量i
再试一次添加关键字。
private static volatile int i = 0;
输出结果:
9632Process finished with exit code 0
可见还是不等于1万,说明还有其他问题,但至少我们排除了内存可见性的问题。
问题三、i++线程不安全i++
在反编译后i++class文件中,它不是原子操作,包括以下三个步骤:
1. getstatic // 读取i的当前值2. iadd// 将i的值加13. putstatic // 将新值写回1
如果在一个线程执行这三个步骤时,另一个线程也执行相同的操作,则结果可能会丢失一次或多次自增。
结合问题二,可以得出结论,volatile虽然能保证内存的可见性,但不能保证原子性。
如何解决既然我们知道这是i++非原子操作的问题,我们就围绕它想办法。
1. 加锁使用synchronized或Lock锁等同步机制
private static int i = 0;public static synchronized void add() { i++;}
多次执行,结果一直是1万,说明问题已经修复。
100000Processprocesss finished with exit code 0
但是你有没有注意到我这里的变量?i
volatile没有用来装饰,难道不存在内存可见性问题吗?
是否忘记了基本知识点:
synchronized
能保证内存的可见性 。 退出一个线程synchronized
当代码块被刷新时,代码块中的所有变量都会被修改为主内存。同时,当另一个线程进入时synchronized
当代码块时,它将从主内存中获得最新的变量值。因此,synchronized
既能保证同时只有一个线程访问共享资源(即相互排斥),又能保证内存的可见性,即其他线程可以看到一个线程对共享变量的修改。
2. 使用原子类需要注意的是,尽管如此
synchronized
保证同时只有一个线程访问共享变量,但不能保证其他非同步访问i
操作的可见性。如果您直接阅读或修改其他地方i
没有使用的值synchronized
或者其他同步机制,可能会出现内存可见性问题。确保一切正确i
访问是可见的,可以i
声明为volatile
。
在Juc的包下,有一个atomic包,它提供了许多原子类,它们都来自Unsafe类,可以保证操作的原子性。
我们将修改代码:
private static AtomicInteger i = new AtomicInteger();public static void add() { i.getAndIncrement();}
经过多次实施,结果一直是10000,说明这种方法也能解决上述问题。
总结通过讨论上述问题,总结知识点:
- volatile可以保证可见性,但不能保证原子性;
- i++线程不安全,可通过加锁或原子类解决;
- synchronized可以保证内存的可见性,但只是修改后的代码块或方法。