当前位置: 首页 > 图灵资讯 > 技术篇> 前段时间面试Java碰到的一道有意思的题目

前段时间面试Java碰到的一道有意思的题目

来源:图灵教育
时间:2023-12-20 17:55:12

看题

前段时间面试遇到一个题目,感觉挺有意思的,特意记录下来。

一般来说,内容是全球变量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

但是你有没有注意到我这里的变量?ivolatile没有用来装饰,难道不存在内存可见性问题吗?

是否忘记了基本知识点:

synchronized能保证内存的可见性 。 退出一个线程synchronized当代码块被刷新时,代码块中的所有变量都会被修改为主内存。同时,当另一个线程进入时synchronized当代码块时,它将从主内存中获得最新的变量值。因此,synchronized既能保证同时只有一个线程访问共享资源(即相互排斥),又能保证内存的可见性,即其他线程可以看到一个线程对共享变量的修改。

需要注意的是,尽管如此 synchronized 保证同时只有一个线程访问共享变量,但不能保证其他非同步访问 i 操作的可见性。如果您直接阅读或修改其他地方 i 没有使用的值 synchronized 或者其他同步机制,可能会出现内存可见性问题。确保一切正确 i 访问是可见的,可以 i 声明为 volatile

2. 使用原子类

在Juc的包下,有一个atomic包,它提供了许多原子类,它们都来自Unsafe类,可以保证操作的原子性。

1702867138313.png

我们将修改代码:

private static AtomicInteger i = new AtomicInteger();public static void add() {    i.getAndIncrement();}

经过多次实施,结果一直是10000,说明这种方法也能解决上述问题。

总结

通过讨论上述问题,总结知识点:

  1. volatile可以保证可见性,但不能保证原子性;
  2. i++线程不安全,可通过加锁或原子类解决;
  3. synchronized可以保证内存的可见性,但只是修改后的代码块或方法。