【Java并发编程】面试题

并发编程基础

为什么有多线程

随着摩尔定律的失效与多核+分布式时代的来临,操作系统可以更多的并行资源,这时需要新的方式来提升系统性能。

从度量的角度,主要是降低延迟,提高吞吐量。因此,我们主要有两个方向,一是优化算法,另一个是将硬件的性能发挥到极致。那计算机主要有哪些硬件呢?主要是两类:一个是 I/O,一个是 CPU。简言之,在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率

操作系统虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程。

守护线程是什么

Java 中的线程分为两种:守护线程(Daemon)和用户线程(User)。任何线程都可以设置为守护线程和用户线程,通过方法Thread.setDaemon(true)则把该线程设置为守护线程,反之则为用户线程。Thread.setDaemon()必须在 Thread.start()之前调用,否则运行时会抛出异常。

两者唯一的区别是判断虚拟机(JVM)何时离开,Daemon 是为其他线程提供服务,如果全部的 User Thread已经关闭,Daemon 没有可服务的线程,JVM会直接关闭。

可见,我们在日常编码中要注意,守护线程不能持有任何需要关闭的资源,例如打开文件等。因为虚拟机退出时,守护线程没有任何机会来关闭文件,这容易导致数据丢失。

线程的生命周期

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,有几种状态呢?在API中java.lang.Thread.State枚举出了六种线程状态:

线程状态 说明
NEW 初始状态,线程刚被创建,但是并未启动(还未调用start方法)。
RUNNABLE 运行状态,JAVA线程将操作系统中的就绪(READY)和运行(RUNNING)两种状态笼统地称为“运行中”。
BLOCKED 阻塞状态,表示线程阻塞于锁。
WAITING 等待状态,表示该线程无限期等待另一个线程执行一个特别的动作。
TIMED_WAITING 超时等待状态,不同于WAITING的是,它可以在指定时间自动返回。
TERMINATED 终止状态,表示当前状态已经执行完

图解如下:

其中涉及主要方法:
  1. Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入 TIMED_WAITING 状态,但不释放对象锁,millis 后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
  2. Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的 CPU 时间片,但不释放锁资源,由运行状态变为就绪状态,让 OS 再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield() 达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield() 不会导致阻塞。该方法与sleep() 类似,只是不能由用户指定暂停多长时间。
  3. t.join()/t.join(long millis),当前线程里调用其它线程 t 的 join 方法,当前线程进入WAITING/TIMED_WAITING 状态,当前线程不会释放已经持有的对象锁,因为内部调用了 t.wait,所以会释放t这个对象上的同步锁。线程 t 执行完毕或者 millis 时间到,当前线程进入就绪状态。其中,wait 操作对应的 notify 是由 jvm 底层的线程执行结束前触发的。
  4. obj.wait(),当前线程调用对象的 wait() 方法,当前线程释放 obj 对象锁,进入等待队列。依靠 notify()/notifyAll()唤醒或者 wait(long timeout) timeout 时间到自动唤醒。唤醒会,线程恢复到 wait 时的状态。
  5. **obj.notify()**唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll() 唤醒在此对象监视器上等待的所有线程。

Runnable和Callable区别

创建线程有三种方式:

  1. 继承Thread类创建线程;
  2. 通过Runnable接口创建线程;
  3. 通过Callable和Future接口创建线程;

Runnable和Callable区别在于:

  • Runnable是自从Java 1.1就有了,而Callable是Java 1.5之后才加上去的;

  • Runnable接口中的 run() 方法的返回值是 void,它做的事情只是纯粹地去执行 run() 方法中的代码,不能抛出异常;

  • Callable接口中的 call() 方法是有返回值的,可以抛出异常,是⼀个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果;

  • 加入线程池运行,Runnable使用ExecutorService的execute方法,Callable使用submit方法。

run()与start()方法区别

start()用来启动一个线程,当调用start()方法时,系统才会开启一个线程(start()调用native start0()方法)来启动的线程处于就绪状态(可运行状态),此时并没有运行,一旦得到CPU时间片,就自动开始执行run()方法。此时不需要等待run()方法执行完也可以继续执行下面的代码,所以也由此看出run()方法并没有实现多线程。

run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有多线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

一个线程两次调用start()方法会出现什么情况?

解答:Java线程是不允许一个线程两次调用start()方法的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start会被认为是编译错误。

在第二次调用 start() 方法的时候,线程可能处于终止或者其他(非 NEW)状态,但是不论如何,都是不可以再次启动的。

wait方法和sleep方法的区别

主要有以下几个不同:

  • 所属的类不同:sleep()属于Thread类,wait()属于Object类;
  • 时间不同:sleep()必须指定时间,wait()可以指定时间也可以不指定时间;
  • 释放锁不同:sleep()释放CPU执行权不释放同步锁;wait()即释放CPU执行权也释放同步锁;
  • 使用的地方不同:sleep()可以在任意地方使用;wait()只能在同步代码方法或者同步代码块中使用;
  • 捕获异常不同:sleep()必须捕获异常;wait()是Object方法,调用不用捕获/抛出异常。

Thread.interrupt() 方法的工作原理

线程的Thread.interrupt()方法用于中断线程,他会设置该线程的中断状态位为true。中断后线程是死亡、还是等待新的任务或是继续运行至下一步,就取决于这个程序本身。线程会不时地检测这个中断标示位,以判断线程是否应该被中断(中断标示值是否为true)。它并不像stop方法那样会中断一个正在运行的线程。

注意Java的中断是一种协作机制。调用线程对象的interrupt方法并不一定就中断了正在运行的线程,它只是要求线程自己在合适的时机中断自己。每个线程都有一个boolean的中断状态(这个状态不在Thread的属性上),interrupt方法仅仅只是将该状态置为true。比如对正常运行的线程调用interrupt()并不能终止它,只是改变了interrupt标示符

一般说来,如果一个方法声明抛出InterruptedException,表示该方法是可中断的,比如wait/sleep/join。也就是说可中断方法会对interrupt调用做出响应(例如sleep响应interrupt的操作包括清除中断状态,抛出InterruptedException),异常都是由可中断方法自己抛出来的,并不是直接由interrupt方法直接引起的。

正是如此,Object.wait()/Thread.sleep()/Thread.join()方法,才会不断的轮询监听 interrupted 标志位,发现其为true后,会停止阻塞并抛出 InterruptedException异常

也就是说,Thread.interrupt()方法不会真正地中断一个正在运行的线程。它主要用于设置线程的中断标示位,在线程受到阻塞的地方(如Thread.sleep()、Thread.join()、Object.wait()检查到线程为“中断状态”后)抛出一个InterruptedException异常,并且“中断状态”也将被清除,这样线程就得以退出阻塞的状态。如果线程没有被阻塞,这时调用 interrupt() 将不起作用,直到执行到 wait/sleep/join 时,才马上会抛出InterruptedException。

什么是线程上下文切换

线程上下文切换的过程,就是一个线程被暂停剥夺使用权,另一个线程被选中开始或者继续运行的过程

由于计算机大多是**抢占式操作系统,**线程上下文切换的原因大概有以下几种:

  • 当前执行任务的时间片用完之后,系统CPU正常调度下一个任务。
  • 当前执行任务碰到IO阻塞,调度器将此任务挂起,继续下一任务。
  • 多个任务抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续下一任务。
  • 用户代码挂起当前任务,让出CPU时间。
  • 硬件中断。

Java程序中,线程上下文切换的主要原因可分为:

  • 程序本身触发的自发性上下文切换:sleep、wait、yield、join、park、synchronized、lock等方法。
  • 系统或虚拟机触发的非自发性上下文切换:线程被分配的时间片用完、JVM垃圾回收(STW、线程暂停)、执行优先级高的线程。

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程

  • 无锁并发编程。多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash取模分段,不同的线程处理不同段的数据。
  • CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  • 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

可见性、原子性、有序性问题产生原因

什么是原子性、可见性、有序性?

  • 原子性:保证指令不会受到线程上下文切换的影响。对共享内存的操作必须是要么全部执行成功,中间过程不能被任何外部因素打断,要么就不执行。

  • 可见性:保证指令不会受CPU缓存的影响。多线程操作共享内存时,执行结果能够及时的同步到共享内存,确保其他线程对此结果可见。

  • 有序性:保证指令不会受CPU指令重排优化的影响。程序的执行顺序按照代码顺序执行,在单线程环境下,程序的执行都是有序的;但是在多线程环境下,JMM 为了性能优化,编译器和处理器会对指令进行重排,程序的执行会变成无序。

为什么会出现可见性、原子性、有序性问题?

并发编程的主要瓶颈还是体现在CPU、内存、I/O 设备三者速度差异的核心矛盾上。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年,那么I/O设备就是十年了。根据木桶理论,程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU增加缓存,以均衡与内存的差异,但是会引发可见性问题;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用,但是会引发有序性问题。
  • 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU和I/O设备的速度差异,但是会引发原子性问题;

其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

线程安全相关

volatile原理

这要从Java内存模型(JMM)说起, JMM定义了线程和主内存的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储该线程以读/写共享变量的副本。

  • 在并发编程场景中,多线程读写共享内存中的全局变量及静态变量容易引发竞态条件,这会导致可见性问题。

  • 在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,这会导致有序性问题。

上述的可见性与有序性的问题,都可以使用volatile关键字解决。

  • **当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值,**这保证了可见性。
  • 此外,happens-before volatile变量规则描述到:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。也就是说,volatile禁止进行指令重排序,这就保证了有序性。

如何实现的呢?(内存屏障 + MESI协议

  • 如何保证可见性?
    1. Java在实现volatile时,就是写入了一条Lock前缀的汇编指令,这个指令会强制CPU缓存数据回写到内存中,然后利用MESI协议让其他CPU的缓存无效化,保证数据的一致性
    2. MESI(修改、独占、共享、无效)即数据一致性协议/写失效协议,指的是只有一个 CPU 核心写入数据,其他的核心只能同步读取到这个写入。而在一个CPU写入缓存后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心,其他的 CPU 核心通过嗅探在总线上传播的数据来检查自己缓存是不是过期,如果发现自己缓存行对应的内存地址被修改,就会将当前的缓存行设置成无效状态,当需要对数据进行修改操作的时候,会重新从系统内存中把数据读到CPU缓存里。
  • 如何保证有序性?
    1. Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。基于保守策略的JMM内存屏障插入策略。
    2. 每个volatile写操作的前插入StoreStore屏障、每个volatile写操作的后插入StoreLoad屏障、每个volatile读操作后插入LoadLoad屏障、 每个volatile读操作后插入LoadStore屏障。
是否能重排序 第二个操作 第二个操作 第二个操作 第二个操作
第一个操作 普通读 普通写 Volatile读 Volatile写
普通读 LoadStore
普通写 StoreStore
Volatile读 LoadLoad LoadStore LoadLoad LoadStore
Volatile写 StoreLoad StoreStore

volatile 缺点

  • 禁止了指令重排,带来一定的性能的问题;
  • 根据MESI缓存一致性协议实现,用到了 CPU 的嗅探机制,需要不断的对内存总线进行内存嗅探,大量交互会导致带宽达到峰值,滥用volatile可能会导致总线风暴

volatile不能保证原子性

  • 对任意单个volatile变量的读/写具有原子性,但类似于 volatile++这种复合操作不具有原子性。为什么呢?
  • 假设有线程A、B同时执行volatile变量 i++操作。根据缓存一致性,一个处理器的缓存回写到主存会导致其他处理器的缓存失效。当线程A,B同时执行i的自增的时候,先执行完的线程A回写数据到主存,导致B的缓存变量无效(因此执行+1操作已经没有意义),直接从主存读取最新的值,这样就少做了一次+1。
  • 那**AtomicInteger为什么能保证原子性呢?**虽然AtomicInteger用的到volatile变量,它的自增方法incrementAndGet()最终调用的Unsafe类的compareAndSetInt()方法,此方法是采用的无锁算法,是一个原子操作。

volatile常见应用——双重检查锁

happens-before原则是什么

JMM可以通过happens-before(先行发生)关系向程序员提供跨线程的内存可见性保证,同时也是对编译器和处理器重排序的约束原则。

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before 于书写在后面的操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递规则:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作。
  6. 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。
  8. 对象终结规则:一个对象的初始化完成,happens-before 它的 finalize() 方法的开始。

synchronized原理

synchronized又称为重量级锁,是实现同步的基础方式之一。它具体使用场景如下:

  1. 修饰普通同步方法,锁是当前实例对象;
  2. 修饰静态同步方法,锁是当前类的Class对象;
  3. 修饰同步方法块,锁是synchonized括号中配置的对象。

可以看到,synchronized都是对对象的加锁。

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头、实例数据和对齐填充。对象头区又主要分为两部分,分别是运行时元数据(Mark Word)和 类型指针。Mark Word用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。

synchronized锁对象就存储在MarkWord中,下面是MarkWord的布局:

查看synchronized加锁代码的字节码文件,你会发现:

  • 当加锁在同步代码块上时,JVM是通过*monitorenter* 和 monitorexit 两个指令进行同步控制的。
  • 当加锁在同步方法时,JVM是通过将会检查方法的 ACC_SYNCHRONIZED 访问标志来判断是否获取monitor。

也就是说,synchronized就是通过monitor机制来实现同步

那什么是monitor呢?

  • monitor,常被翻译为“监视器”或者“管程”。它是一种同步原语,相较于操作系统同步原语(semaphore 信号量 和 mutex 互斥量)更加的高层次。

Java是如何实现monitor呢?

  • 在Java虚拟机(HotSpot)中,**Monitor是由ObjectMonitor实现的。**每个 Java 对象在 JVM 的对等对象的头中Mark Word区域保存锁状态,指向ObjectMonitor

ObjectMonitor中有两个队列,WaitSetEntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),owner指向持有ObjectMonitor对象的线程,处理详细流程如下:

  1. 加锁时,即遇到synchronized关键字时,线程会先进入monitor的EntryList队列阻塞等待。
  2. 如果monitor的owner为空,则从队列中移出并赋值与owner。
  3. 如果在程序里调用了wait()方法,则该线程进入WaitSet队列。我们都知道wait方法会释放monitor锁,即将owner赋值为null并进入WaitSet队列阻塞等待。这时其他在EntryList中的线程就可以获取锁了。
  4. 当程序里其他线程调用了notify/notifyAll方法时,就会唤醒WaitSet中的某个线程,这个线程就会再次尝试获取monitor锁。如果成功,则就会成为monitor的owner。
  5. 当程序里遇到synchronized关键字的作用范围结束时,就会将monitor的owner设为null,退出。

以上就是synchronized原理。

synchronized与Lock区别

  • synchronized属于JVM层面,底层通过 monitorentermonitorexit 两个指令实现,Lock是API层面的东西,JUC提供的具体类。
  • synchronized不需要用户手动释放锁,当synchronized代码执行完毕之后会自动让线程释放持有的锁,Lock需要一般使用try-finally模式去手动释放锁。
  • synchronized是不可中断的,除非抛出异常或者程序正常退出,Lock可中断,使用lockInterruptibly,调用interrupt方法可中断。
  • synchronized是非公平锁,Lock默认是非公平锁,但是可以通过构造函数传入boolean类型值更改是否为公平锁。
  • 锁是否能绑定多个条件,synchronized没有condition的说法,要么唤醒所有线程,要么随机唤醒一个线程,Lock可以使用condition实现分组唤醒需要唤醒的线程,实现精准唤醒。
  • synchronized 锁只能同时被一个线程拥有,但是 Lock 锁没有这个限制,例如在读写锁中的读锁,是可以同时被多个线程持有的,可是 synchronized做不到。
  • 性能区别:在 Java 5 以及之前synchronized的性能比较低,但是到了 Java 6 以后 JDK 对 synchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等。

Java中如何进行锁优化

在 JDK 1.6 中 HotSopt 虚拟机对 synchronized 内置锁的性能进行了很多优化,包括自旋锁、自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。

自旋锁

自旋锁可以减少线程阻塞造成的线程切换。其执行步骤如下:

  1. 当前线程尝试去竞争锁。
  2. 竞争失败,准备阻塞自己。
  3. 但是并没有阻塞自己,进入自旋状态(空等待,比如一个空的有限for循环)。
  4. 自旋状态下,继续竞争锁。
  5. 如果自旋期间成功获取锁,那么结束自旋状态,否则进入阻塞状态。

自旋锁适合在持有锁时间短,并且竞争激烈的场景下使用。在JDK1.6中自旋锁默认开启。可以使用*-XX:+UseSpinning开启,-XX:-UseSpinning关闭自旋锁优化。自旋的默认次数为10次,可以使用-XX:preBlockSpin*参数修改默认的自旋次数。

自适应的自旋锁

适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋10次。他可以根据它前面线程的自旋情况,从而调整它的自旋。

锁消除/同步省略

经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。

锁粗化

同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

Java 锁升级过程

synchronized通过monitor机制来实现线程同步,而monitor机制有依赖于操作系统的mutex lock实现。当每次获取锁/释放锁都会涉及内核态与用户态的转换,成本高,所以synchronized又被称为重量级锁。

为了减少获得锁和释放锁带来的性能消耗,从JDK 6开始,引入了“偏向锁”和“轻量级锁”。所以,目前锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级锁可以升级但不能降级,为的是提高获得锁与释放锁的效率。

synchronized其实是可以锁降级的,当JVM进入安全点(SafePoint)的时候(垃圾回收时),会检查是否有闲置的 monitor,有的话试图进行降级,降级对象为仅仅能被 VMThread 访问而没有其他 JavaThread 访问的对象,所以我们在正常使用synchronized的时候,自然认定只有锁升级没有锁降级。

  • synchronizer.cppdeflate_idle_monitors是分析锁降级逻辑的入口,这部分行为还在进行持续改进,因为其逻辑是在安全点内运行,处理不当可能拖长 JVM 停顿(STW,stop-the-world)的时间。
  • fast_exit 或者 slow_exit 是对应的锁释放逻辑。

四种锁状态对应的的Mark Word内容描述如下:

在64位虚拟机下,Mark Word在不同锁状态存储结构如下:

**无锁**:通过CAS操作来加锁(CAS原理下面讲解)

偏向锁:锁总是由同一线程获得,锁升级为偏向锁。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可

**轻量级锁**:偏向锁多应用只有一个线程访问同步块场景中,一旦
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值