java 面试题 - 多线程
1.synchronized 原理以及 jdk1.6 后的底层优化
synchronized 关键字经过编译器优化后,会在同步块前后分别形成 monitorenter 和 monitorexit 字节码指令,这两个字节码指令都需要一个 refenence 类型的参数来指明要锁定和解锁的对象。如果 java 源码中的 synchronized 明确指定了对象参数,那就以这个对象的引用作为 reference,如果没有明确指定,那将根据 synchronized 修饰的方法类型,来决定是取代码所在的对象实例还是取类型对应的 class 对象来作为线程要持有的锁。
根据《java 虚拟机规范》的要求,在执行 montiroenter 指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了,如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有他的线程释放为止。
---《深入理解 java 虚拟机----第 13 章线程安全和锁的优化》
在说明 synchronzied 的优化手段时,首先明确为什么需要优化,主要是由于 java 的线程时映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免的陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。
优化手段
自旋锁和自适应锁::没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁。自旋锁虽然避免了线程切换的开销,但是占用了处理器的时间,如果时间很长,那就会消耗处理器的资源。而自适应锁意味着尝试次数时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
锁消除:锁消除指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把他们当作栈上数据对待,认为他们是线程私有的,同步加锁自然就无需进行。
锁粗化:如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体之中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到由这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。
轻量级锁:在 hotspot 虚拟机对象头中存在 markword 数据结构,它里面存有锁的状态,分为未锁定,轻量级锁定,重量级锁定,gc 标记,可偏向。他的工作原理为,代码即将进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程栈帧中建立一个名为锁记录的空间,虚拟机尝试使用 cas 操作把对象的 mark word 更新为指向 lock record 的指针,如果这个成功了,代表该线程拥有了这个对象的锁,并锁标识位改变,如果更新失败,那么意味着存在线程竞争当前对象的锁。虚拟机首先会检查对象的 mark word 是否指向当前线程的栈帧,如果是证明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了。否则说明这个线程已经被其他线程抢占了,如果存在两条以上线程争用同一个锁的情况,那轻量级锁就不再有效,必须膨胀为重量级锁,此时 mark word 中存储的就是指向重量级锁的指针,后面等待锁的线程也必须进入阻塞状态。解锁过程也同样是通过 cas 操作来进行的,如果对象的 mark word 仍然指向线程的锁记录,那就用 cas 操作把对象当前的 mark word 和线程中复制的 displaced mark word 替换回来,假如能够成功替换,那整个同步过程就顺利完成了,如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。轻量级锁提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则,如果没有竞争,轻量级锁便通过 cas 操作成功避免了使用互斥量的开销,但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 cas 操作的开销,因此在有竞争的情况下轻量级锁反而会比传统的重量级锁更慢。
偏向锁:待添加
总结:synchronized 底层主要是在读写前后加上了 monitorenter 和 monitorexit 指令,如果执行 montorenter 时,已经持有锁护或者没被锁定,那么锁的计数值增加一,相反就执行减一,一旦计数器的值为 0,锁就被释放。synchronized 主要问题是阻塞同步的,相同时间只有一个线程能获取到对象,其他线程都阻塞。
synchronized 和 ReenTrantLock 的区别
synchronized 底层 reentranlock 可重入锁底层是 lock 接口的实现,
synchronized 是同步阻塞,采用的是悲观并发策略,reentranlock 同步非阻塞,采用的乐观并发策略,reentranlock 使用时需要手动释放,synchronized 则是自动的。发生异常时,synchronzied 会自动释放,而 lock 在发生异常时,如果没有通过 unlock 主动释放,则可能造成死锁,因此需要在 unfinally 中释放。此外 reentranlock 还提供一些方法,如判断是否为公平锁,锁是否被获取,锁是否被当前线程获取等等。
volatile
volatile 作用于变量上,底层原理是 lock,内存屏障,在读写的时候前后各添加一个 store 屏障,避免了指令重排。解决可见性的问题是在内存操作上主动把值刷新到主内存中,jmm 会将线程对象的 cpu 内存设置过期,从内存读取最新值。在 jmm 的 happen-before 原则中有关于这一项。
说下对悲观锁和乐观锁的理解
乐观锁:乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景。乐观锁常见两种实现方式:cas 和版本号控制。
悲观锁:每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作。
2.case 和 synchronized
cas 不需要进入内核, 不需要切换线程,操作几率内旋较少可以获得较大的性能。并发量大的情况下,大幅降低性能,浪费 cpu 资源。没有锁竞争带来的系统开销,也没有线程间频繁调度的开销。缺点是,长时间的自旋等待也会消耗大量 cpu 以及 aba 问题。 使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 cpu 资源。
3.fork/join 框架和与线程池的区别
fork/join 框架原理就是拆分、聚合、合并结果,是一个并发执行计算的框架,fork 将一个大任务拆分成多个子任务,子任务分别去计算,而 join 获取到并发执行的结果,然后进行合并,这时一个递归的过程。与线程池的区别,是所有线程共用一个线程池一个任务队列,而 fork/join 主要是多个线程多个任务队列,然后利用工作窃取提高效率。主要是为了提高解决任务的效率,适用于计算密集型的任务。
4.线程池的线程数量设置
线程池设置数量主要和任务是 io 密集型还是 cpu 密集型有关系。
io 密集型:数据库数据交互、文件上传下载、网络数据传输等等。这里为了防止线程进行上下文切换带来的耗时,线程数不可过多,就跟 cpu 差不多或一样即可。
cpu 密集型:复杂数据计算。主要影响的是 io 计算耗时/cpu 计算耗时。
这里只是理论上的计算,之后还要对不同场景进行压测来看实际的运行情况。
5.线程池应用场景和如何创建相关参数?
第一个,降低资源消耗,通过重复利用已创建的线程,降低线程创建和销毁造成的消耗。
第二个,提高响应速度,当有任务时,任务可以不需要等待线程创建就能立即执行。
第三个,提高现成的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
在构造函数中,keepAliveTime 表示线程闲置超时时长。如果线程闲置时间超过该时长,非核心线程就会被回收。如果将 allowCoreThreadTimeout 设置为 true 时,核心线程也会超时回收。
核心线程和非核心线程没有本质区别。
1.先看线程数是否大于核心线程,否则创建核心线程执行任务。
2.如果大于核心线程,则放入阻塞队列。
3.如果队列满了,则创建非空闲线程直到线程数等于 maximupoolsize。
4.如果都满了就执行拒绝策略。
ThreadPoolExecutor 线程池有如下几种状态:
RUNNING:运行状态,接受新任务,持续处理任务队列里的任务;
SHUTDOWN:不再接受新任务,但要处理任务队列里的任务;
STOP:不再接受新任务,不再处理任务队列里的任务,中断正在进行中的任务;
TIDYING:表示线程池正在停止运作,中止所有任务,销毁所有工作线程,当线程池执行 terminated()方法时进入 TIDYING 状态;
TERMINATED:表示线程池已停止运作,所有工作线程已被销毁,所有任务已被清空或执行完毕,terminated()方法执行完成。
总共有 5 个状态,运行、停止、销毁、关闭、整理。创建开始是运行状态,使用 shutdown 是变成 shutdown 状态,使用 shutdownnow 是 stop 状态,最后统一进入 tidying、terminated 状态。
6.java 原子类
基本类型
AtomicBoolean:布尔类型原子类;
AtomicInteger:整型原子类;
AtomicLong:长整型原子类。
引用类型
AtomicReference:引用类型原子类;
AtomicMarkableReference:带有标记位的引用类型原子类;
AtomicStampedReference:带有版本号的引用类型原子类。
数组类型
AtomicIntegerArray:整形数组原子类;
AtomicLongArray:长整型数组原子类;
AtomicReferenceArray:引用类型数组原子类。
属性更新器类型
AtomicIntegerFieldUpdater:整型字段的原子更新器;
AtomicLongFieldUpdater:长整型字段的原子更新器;
AtomicReferenceFieldUpdater:原子更新引用类型里的字段;
原子类相比锁更加的轻量级,原子变量将发生竞争的范围缩小到单个变量上。原子类是通过自旋 CAS 操作 volatile 变量实现的,使用 volatile 变量是为了多个线程间变量的值能及时同步。而 synchronized 原理是 montior 锁,synchronized 的范围更加广泛,能修饰代码,也能修饰方法。所以仅有少量的场景,例如计数器等场景,可以使用原子类。而在其他更多的场景下,如果原子类不适用,那么就可以考虑用 synchronized 来解决这个问题。在竞争非常激烈的情况下,推荐使用 synchronized;而在竞争不激烈的情况下,使用原子类会得到更好的效果。因为 synchronized 是一种典型的悲观锁,而原子类恰恰相反,它利用的是乐观锁。所以,在比较 synchronized 和 AtomicInteger 的时候,其实也就相当于比较了悲观锁和乐观锁的区别;从性能上来考虑的话,悲观锁的操作相对来讲是比较重量级的。因为 synchronized 在竞争激烈的情况下,会让拿不到锁的线程阻塞,而原子类是永远不会让线程阻塞的。不过,虽然 synchronized 会让线程阻塞,但是这并不代表它的性能就比原子类差。 主要问题还是 aba 问题,通过版本号控制解决。常用的应用场景是计时器。
7.java 并发容器 ConcurrentHashMap 和 CopyOnWriteArrayList
CopyOnWriteArrayList 实现线程安全主要通过锁和读写分离,在进行新增、删除、修改操作的时候会加锁,在进行写操作的时候会 copy 数组,在新数组上进行操作,而读取的是原来的老数组。当新数组操作完毕之后才会指向旧数组进行更新。
可以看到是通过 ReentrantLock 独占锁实现并发的安全写操作。但是相应的带来缺点,由于是写时复制,所以占用内存,写的时候读由于读的是旧数据,所以只能实现最终一致性,无法实现实时的一致性。
待添加 concurrenthashmap
8.submit 和 execute 的区别
方法签名不同: execute(Runnable command) 接受一个 Runnable 类型的参数,而 submit(Runnable task) 接受一个 Runnable 或者 Callable 类型的参数,因此 submit() 方法可以返回一个结果。
异常处理方式不同:如果任务在执行过程中抛出异常, execute()方法不会显示抛出异常,而是将其捕获并记录,而 submit()方法则会将异常包装在 Future 对象中返回。
返回值不同:execute()方法没有返回值,而 submit()方法会返回一个 Future 对象,通过该对象可以获取任务执行的状态、结果等信息。
阻塞行为不同:execute()方法一旦提交任务就立即返回,无法阻塞;而 submit()方法可以选择传递一个超时时间作为参数,如果在指定时间内任务没有完成,则取消任务并抛出异常。
总之,execute()
更加简单直接,适用于不需要关心任务的返回值以及异常处理的情况,而submit()
则更加灵活,适用于需要获取任务的执行状态、结果,并进行相应的处理的情况。
9.线程创建的方式和优劣
1.实现 callable 结合 futuretask 使用,使用 future 能获取返回结果,任务执行情况等等。
2.使用 CompletableFuture 类创建异步线程
3.继承 thread 类创建线程
4.实现 runnable,原理也是使用 thread,实现 runnable 然后将其作为构造函数参数来创建线程,适合多个线程共享同一资源的情况。
Callable 规定(重写)的方法是 call(),Runnable 规定(重写)的方法是 run()。
Callable 的任务执行后可返回值,而 Runnable 的任务是不能返回值的。
Call 方法可以抛出异常,run 方法不可以。
运行 Callable 任务可以拿到一个 Future 对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过 Future 对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
5.使用 ExecutorService、Callable(或者 Runnable)、Future 实现由返回结果的线程。
10.线程的中断停止
interrupt():将调用者线程的中断状态设为 true。
isInterrupted():判断调用者线程的中断状态。
interrupted():只能通过 Thread.interrupted()调用。1.返回当前线程的中断状态;2.将当前线程的中断状态设为 false;
interrupt 无法中断线程,只能把标志为设置为 true,然后在 while 判断中断标志位进行处理逻辑。还可以自定义一个共享中断标识,volatile 修饰。两种方法本质上是一样的,而且遵循的逻辑都是让线程自己去处理中断。
11.run 和 start 的区别
run 是同步方法,并不会开新线程,start 是异步方法会新开一个线程。该启动的线程不会马上运行,会放到等待队列中等待 CPU 调度,只有线程真正被 CPU 调度时才会调用 run() 方法执行。所以 start() 方法只是标识线程为就绪状态的一个附加方法,以下 start() 方法的源码,其中 start0() 是一个本地 native 方法。
评论