目录
1. 面试题一:在 Java 程序中怎么保证多线程的运行安全?
线程安全在三个方面体现:
- 「原子性」:提供互斥访问,同⼀时刻只能有⼀个线程对数据进行操作;
- 「可见性」:⼀个线程对主内存的修改可以及时地被其他线程看到;
- 「有序性」:⼀个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果⼀般杂乱无序(happens&before 原则)。
补充:
- 原子性指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
- 可见性指当一个线程修改了某一个共享变量的值,其他的线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为我们在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量一定是修改后的值。
- 有序性指对于一个线程的执行代码,我们习惯性的认为代码的执行是从前往后,依次执行的。但是在并发时,程序的执行可能会出现乱序。给人直观的感觉就是:写在前面的代码,可能会在后面执行。
1.1 追问一:Java 线程同步的几种方法?
- 「同步方法」:即有「synchronized」关键字修饰的方法,由于 Java 的每个对象都有一个内置锁,当用此关键字修饰方法时, 内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意, synchronized 关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
- 「同步代码块」:即有「synchronized」关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用「synchronized」代码块同步关键代码即可。
- 「ReentrantLock」:Java 5 新增了一个 java.util.concurrent 包来支持同步,其中 ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁,它与使用 synchronized 方法和快具有相同的基本行为和语义,并且扩展了其能力。需要注意的是,ReentrantLock 还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,因此不推荐使用。
- 「volatile」:volatile 关键字为域变量的访问提供了一种免锁机制,使用 volatile 修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile 不会提供任何原子操作,它也「不能用来修饰 final 类型的变量」。
- 「原子变量」:在 Java 的 java.util.concurrent.atomic 包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如 AtomicInteger 表可以用原子方式更新 int 的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换 Integer。可扩展 Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。
2. 面试题二:JMM
关于「JMM」的文章请看这位大佬的博文->>>【Java线程】Java内存模型总结
总结:JMM通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。
3. 面试题三:源代码与指令间的重排序
为了提高性能,编译器和处理器常常会对指令做重排序。
重排序有 3 种类型,其中后 2 种都是处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。
- 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须要考虑指令之间的数据依赖性。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保持一致性是无法确定的,结果无法预测。
4. 面试题四:重排序对可见性的影响
参考下表,虽然处理器执行的顺序是 A1->A2 ,但是从内存角度来看,实际发生的顺序是 A2->A1 。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此它们都会允许对写-读操作执行重排序。