likes
comments
collection
share

Java多线程面试系列——为什么需要多线程

作者站长头像
站长
· 阅读数 5

在Android开发的面试中,Java多线程的问题是绕不开的。这个系列主要介绍面试过程中涉及到的多线程的知识点,以及相关的面试题。这是本系列的第一篇,介绍多线程的目的、多线程编程会出现问题的原因以及解决方式。

为什么需要多线程

我们都知道CPU缓存、内存、IO设备之间读取速度相差非常大。如下图所示,程序的性能受限于IO设备,无论怎么提高CPU、内存的速度都没有用。。为了解决这个问题,操作系统就提出了多进程、多线程的机制,通过分配时间片的方式来提高CPU的利用率。

Java多线程面试系列——为什么需要多线程

上面图片来源每个程序员都应该知道的延迟数字,是2020年的耗时数据。如果需要看最新的数据,可以看伯克利大学制作的网页

面试题1:进程和线程的区别?为什么要有线程,而不是仅仅是用进程?

  • 从概念上说,进程是系统中正在运行的应用程序,而线程是应用程序中的不同执行路径
  • 从目的上说,进程和线程都是为了解决CPU、内存、IO设备之间读取速度相差过大的问题,不过线程的切换比进程更轻量
  • 从开发上说,进程之间不能共享资源,而线程之间是可以共享同一进程的资源

面试题2:假如只有一个cpu,单核,多线程还有用吗 ?

操作系统使用多线程机制是为了解决CPU、内存、IO设备之间读取速度相差过大的问题。就算是只有一个CPU、单核,在处理IO操作时,也可以采用多线程机制来提高CPU的利用率。

多线程编程为什么容易出问题

在多线程编程中,我们遇到的问题都可以归纳到多线程的可见性、原子性、有序性上去。三个特性介绍如下。需要注意想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确

  • 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 原子性:即一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。
  • 有序性:程序执行的顺序按照代码的先后顺序执行。

为什么可见性有问题

Java多线程面试系列——为什么需要多线程

为了解决CPU、内存、IO设备之间读取速度相差过大的问题,除了操作系统的多进程、多线程机制外,CPU还增加了缓存,以均衡与内存的速度差异。

在单核时代,每个线程都共有一个缓存,因此不同的线程对变量的操作有可见性。但是在多核时代(如上图所示),每个 CPU 都有自己的缓存(L1和L2),当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存,因此不同的线程对变量的操作就不具有可见性了。

为什么原子性有问题

多线程原子性的问题有两个原因。其一是大部分程序代码的执行不是原子性的。比如num++(num为0)这条代码需要三条CPU指令:步骤1:把变量 num 从内存加载到 CPU 的寄存器;步骤2:在寄存器中执行 +1 操作;步骤3:将结果写入内存。其二是线程的切换,当线程1执行到步骤1时,这时线程1时间片用完了;如果此时还有线程2,它也执行了步骤1;这时两个线程执行的结果为 1,而不是2.

为什么有序性有问题

int i = 0;
boolean flag = false;
i = 1;                //语句1
flag = true;          //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序。指令重排序是指编译器为了优化性能,它有时候会改变程序中语句的先后顺序。需要注意指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性

如何解决可见性、原子性、有序性的问题

在Java中,通过定义了JMM(Java内存模型)来解决这个问题。JMM主要有两个作用:

功能一:使java程序在各种平台下都能达到一致的并发效果。因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。使用java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的并发效果。

具体模型如下图,Java内存模型规定所有的变量都存储在主内存中,包括实例变量,静态变量,但是不包括局部变量和方法参数。每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行线程不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。

Java多线程面试系列——为什么需要多线程

功能二:保证代码的原子性,可见性,有序性。JMM定义了volatile、synchronized 和 final 三个关键字,以及八项Happens-Before 规则的规范来解决可见性、原子性、有序性的问题。需要注意JMM只是定义规范,具体实现是由JVM完成的。

八项happens-before原则:

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

可见性、原子性、有序性的问题的解决方式

Java多线程面试系列——为什么需要多线程

  • 解决可见性问题

如上图所示,java的 volatile、final、synchronized 关键字都可以实现可见性。

  1. 被 volatile 修饰的变量,它的值被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。通过这种方式保证可见性。
  2. synchronized 包裹的代码块或者修饰的方法,在执行完之前,会将共享变量同步到主内存中,从而保证可见性。
  3. 被final修饰的字段,初始化完成后对于其他线程都是可见的。需要注意的是,如果final修饰的是引用变量,对它属性的修改是不可见的。
  • 解决有序性问题
  1. volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
  2. 如果你了解过DCL单例模式,应该知道synchronized内部的代码是会指令重排序的。那为什么说synchronized能保证有序性呢?因为synchronized保证的有序性是指它修饰的方法或者代码块内部的代码,经过重排序不会在锁外,而不是确保synchronized内部的有序性
  • 解决原子性问题

    synchronized包裹的代码块或者修饰的方法保证了只有一个线程执行,确保了代码块或者方法的原子性。

内存屏障是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

面试题3:sychronied修饰普通方法和静态方法的区别?

使用synchronied修饰普通方法,等价于synchronized(this){},是给当前类的对象加锁;使用synchronied静态方法,等价于synchronized(class){},是给当前类对象加锁。需要注意,synchronized不可以修饰类的构造方法,但是可以在构造函数里面使用synchronied代码块。

面试题4:构造函数为什么不需要synchronized修饰方法?构造函数是线程安全的吗?

在java中,我们是通过new关键字来获取对象。如果多线程执行new,每个线程都会获取一个对象,因此构造函数不需要synchronized来修饰。

但是构造函数是线程安全的吗?答案是不安全的。原因有两个:

  1. 构造函数内部会指令重排序,比如构造函数内部的变量经过指令重排序,其位置可能在构造函数之外。
  2. 创建对象的指令不是原子性的,可能因为指令重排序造成各种问题

面试题5:volatile关键字做了什么?

volatile关键字保证内存可见性和禁止了指令重排。

volatile修饰的变量,它的值被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值

volatile修饰的变量禁止了指令重排序。volatile修饰的变量,在读写操作指令前后会插入内存屏障,这样指令重排序时就不会把后面的指令重排序到内存屏障前

面试题6:DCL中单例成员为什么需要加上volatile关键字

public class SingletonClass {

    private volatile static SingletonClass instance = null;

    private SingletonClass() {
    }

    public static SingletonClass getInstance() {
        if (instance == null) {
             synchronized (SingletonClass.class) {
                   if(instance == null) {
                     instance = new SingletonClass();
                  }
            }
        }
        return instance;
    }

}

这是因为创建对象的指令不是原子性的,有三步

  1. 分配内存
  2. 初始化对象
  3. 将内存地址赋值给引用

如果发生了指令重排可能会导致第二步内容和第三步内容顺序发生变化,即还没初始化的对象已经赋值给引用。此时另一个线程会获取还没有初始化的对象,这时对对象的操作可能会造成各种问题。

面试题7:volatile和synchronize有什么区别?

  • volatile 只能作用于变量,synchronized 可以作用于变量、方法。
  • volatile 只保证了可见性和有序性,无法保证原子性,synchronized 可以保证有序性、原子性和可见性。
  • volatile 不阻塞线程,synchronized 会阻塞线程

面试题8:为什么局部变量是线程安全的

Java多线程面试系列——为什么需要多线程

如上图所示,局部变量都是放到了java调用栈里,而每个线程都有自己独立的调用栈。

参考

转载自:https://juejin.cn/post/7349119662133837839
评论
请登录