本篇主要总结JAVA面试中关于并发相关的高频面试题。本篇的面试题基于网络整理,和自己编辑。在不断的完善补充哦。
程序,是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。
进程,是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。
线程,与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
- 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。
- 从另一角度来说,进程属于操作系统的范畴,主要是同一段时间内,可以同时执行一个以上的程序,而线程则是在同一程序内几乎同时执行一个以上的程序段。
- 使用多线程可以把程序中占据时间长的任务放到后台去处理,如图片、视屏的下载。
- 发挥多核处理器的优势,并发执行让系统运行的更快、更流畅,用户体验更好。
- 大量的线程降低代码的可读性。
- 更多的线程需要更多的内存空间。
- 当多个线程对同一个资源出现争夺时候要注意线程安全的问题。
Java 中的线程分为两种:守护线程(Daemon)和用户线程(User)。
- 任何线程都可以设置为守护线程和用户线程,通过方法
Thread#setDaemon(boolean on)
设置。true
则把该线程设置为守护线程,反之则为用户线程。Thread#setDaemon(boolean on)
方法,必须在Thread#start()
方法之前调用,否则运行时会抛出异常。
唯一的区别是:
程序运行完毕,JVM 会等待非守护线程完成后关闭,但是 JVM 不会等待守护线程。
- 判断虚拟机(JVM)何时离开,Daemon 是为其他线程提供服务,如果全部的 User Thread 已经撤离,Daemon 没有可服务的线程,JVM 撤离。
- 也可以理解为守护线程是 JVM 自动创建的线程(但不一定),用户线程是程序创建的线程。比如,JVM 的垃圾回收线程是一个守护线程,当所有线程已经撤离,不再产生垃圾,守护线程自然就没事可干了,当垃圾回收线程是 Java 虚拟机上仅剩的线程时,Java 虚拟机会自动离开。
扩展:Thread Dump 打印出来的线程信息,含有 daemon 字样的线程即为守护进程。可能会有:服务守护进程、编译守护进程、Windows 下的监听 Ctrl + break 的守护进程、Finalizer 守护进程、引用处理守护进程、GC 守护进程。
多线程会共同使用一组计算机上的 CPU ,而线程数大于给程序分配的 CPU 数量时,为了让各个线程都有执行的机会,就需要轮转使用 CPU 。
不同的线程切换使用 CPU 发生的切换数据等,就是上下文切换。
- 在上下文切换过程中,CPU 会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB)中的。PCB还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到CPU的内存中,直到他们被再次使用。
- 上下文切换是存储和恢复 CPU 状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。
假设计算机只有一个 CPU ,则在任意时刻只能执行一条机器指令,每个线程只有获得 CPU 的使用权才能执行指令。
- 所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得 CPU 的使用权,分别执行各自的任务。
- 在运行池中,会有多个处于就绪状态的线程在等待 CPU ,Java 虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配 CPU 的使用权。
有两种调度模型:分时调度模型和抢占式调度模型。
- 分时调度模型是指让所有的线程轮流获得 CPU 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。
Java 虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用 CPU ,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用 CPU 。处于运行状态的线程会一直运行,直至它不得不放弃 CPU 。
如非特别需要,尽量不要用,防止线程饥饿。
7、什么是线程饥饿?
饥饿,一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。
Java 中导致饥饿的原因:
- 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
- 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。
每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。
- 我们可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。线程优先级是一个 int 变量(从1-10),1 代表最低优先级,10 代表最高优先级。
- Java 的线程优先级调度会委托给操作系统去处理,所以与具体的操作系统优先级有关,如非特别需要,一般无需设置线程优先级。
线程一共有五个状态,分别如下:
Thread t1 = new Thread()
。可运行(runnable):线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权。例如:t1.start()
。
运行(running):线程获得 CPU 资源正在执行任务(#run()
方法),此时除非此线程自动放弃 CPU 资源或者有优先级更高的线程进入,线程将一直运行到结束。
#run()
方法,终止。#stop()
方法,让一个线程终止运行。堵塞(blocked):由于某种原因导致正在运行的线程让出 CPU 并暂停自己的执行,即进入堵塞状态。直到线程进入可运行(runnable)状态,才有机会再次获得 CPU 资源,转到运行(running)状态。阻塞的情况有三种:
正在睡眠:调用 #sleep(long t)
方法,可使线程进入睡眠方式。
一个睡眠着的线程在指定的时间过去可进入可运行(runnable)状态。
正在等待:调用 #wait()
方法。
调用
notify()
方法,回到就绪状态。
被另一个线程所阻塞:调用 #suspend()
方法。
调用
#resume()
方法,就可以恢复。
整体如下图所示:
Thread 的线程状态
如下是另外一个图,把阻塞的情况,放在了一起,也可以作为参考:
Thread 的线程状态
无意中,又看到一张画的更牛逼的,如下图:
Thread 的线程状态
如何结束一个一直运行的线程?
一般来说,有两种方式:
方式一,使用退出标志,这个 flag 变量要多线程可见。
在这种方式中,之所以引入共享变量,是因为该变量可以被多个执行相同任务的线程用来作为是否中断的信号,通知中断线程的执行。
方式二,使用 interrupt 方法,结合 isInterrupted 方法一起使用。
如果一个线程由于等待某些事件的发生而被阻塞,又该怎样停止该线程呢?这种情况经常会发生,比如当一个线程由于需要等候键盘输入而被阻塞,或者调用
Thread#join()
方法,或者Thread#sleep(...)
方法,在网络中调用ServerSocket#accept()
方法,或者调用了DatagramSocket#receive()
方法时,都有可能导致线程阻塞,使线程处于处于不可运行状态时。即使主程序中将该线程的共享变量设置为true
,但该线程此时根本无法检查循环标志,当然也就无法立即中断。这里我们给出的建议是,不要使用
Thread#stop()· 方法,而是使用 Thread 提供的
#interrupt()` 方法,因为该方法虽然不会中断一个正在运行的线程,但是它可以使一个被阻塞的线程抛出一个中断异常,从而使线程提前结束阻塞状态,退出堵塞代码。
所以,方式一和方式二,并不是冲突的两种方式,而是可能根据实际场景下,进行结合。
一个线程如果出现了运行时异常会怎么样?
如果这个异常没有被捕获的话,这个线程就停止执行了。
另外重要的一点是:如果这个线程持有某个某个对象的监视器,那么这个对象监视器会被立即释放。
Java 中创建线程主要有三种方式:
- 方式一,继承 Thread 类创建线程类。
- 方式二,通过 Runnable 接口创建线程类。
- 方式三,通过 Callable 和 Future 创建线程。
- 优点:编写简单,如果需要访问当前线程,则无需使用
Thread#currentThread()
方法,直接使用this
即可获得当前线程。- 缺点:线程类已经继承了 Thread 类,所以不能再继承其他父类。
使用方式二、或方式三
优点:【最重要】可以使用线程池。在这种方式下,多个线程可以共享同一个 target
对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
Runnable runner = new Runnable(){ ... }; // 通过new Thread(target, name) 方法创建新线程 new Thread(runna,"新线程1").start(); new Thread(runna,"新线程2").start(); |
缺点:编程稍微复杂,如果要访问当前线程,则必须使用Thread#currentThread()
方法。
- 当你调用 start 方法时,你将创建新的线程,并且执行在 run 方法里的代码。
- 但是如果你直接调用 run 方法,它不会创建新的线程也不会执行调用线程的代码,只会把 run 方法当作普通方法去执行。
如果异常没有被捕获该线程将会停止执行。
Thread.UncaughtExceptionHandler
是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口。当一个未捕获异常将造成线程中断的时候 JVM 会使用Thread#getUncaughtExceptionHandler()
方法来查询线程的 UncaughtExceptionHandler 并将线程和异常作为参数传递给 handler 的#uncaughtException(exception)
方法进行处理。
在 Java 发展史上,曾经使用 suspend、resume 方法对于线程进行阻塞唤醒,但随之出现很多问题,比较典型的还是死锁问题。
解决方案可以使用以对象为目标的阻塞,即利用 Object 类的 wait 和 notify方法实现线程阻塞。
- 首先,wait、notify 方法是针对对象的,调用任意对象的 wait 方法都将导致线程阻塞,阻塞的同时也将释放该对象的锁,相应地,调用任意对象的 notify 方法则将随机解除该对象阻塞的线程,但它需要重新获取改对象的锁,直到获取成功才能往下执行。
- 其次,wait、notify 方法必须在
synchronized
块或方法中被调用,并且要保证同步块或方法的锁对象与调用 wait、notify 方法的对象是同一个,如此一来在调用 wait 之前当前线程就已经成功获取某对象的锁,执行 wait 阻塞后当前线程就将之前获取的对象锁释放。
- sleep 方法,是线程类 Thread 的静态方法。调用此方法会让当前线程暂停执行指定的时间,将执行机会(CPU)让给其他线程,但是对象的锁依然保持,因此休眠时间结束后会自动恢复(线程回到就绪状态)
- wait 方法,是 Object 类的方法。调用对象的
#wait()
方法,会导致当前线程放弃对象的锁(线程暂停执行),进入对象的等待池(wait pool),只有调用对象的#notify()
方法(或#notifyAll()
方法)时,才能唤醒等待池中的线程进入等锁池(lock pool),如果线程重新获得对象的锁就可以进入就绪状态。
- wait 方法,使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁。
- sleep 方法,使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理 InterruptedException 异常。
- notify 方法,唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关。
- notityAll 方法,唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态。
当一个线程进入 wait 之后,就必须等其他线程 notify/notifyAll 。
- 使用 notifyAll,可以唤醒所有处于 wait 状态的线程,使其重新进入锁的争夺队列中,而 notify 只能唤醒一个。
- 如果没把握,建议 notifyAll ,防止 notify 因为信号丢失而造成程序错误。
一个很明显的原因是 Java 提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。
由于 wait,notify 和 notifyAll 方法都是锁级别的操作,所以把它们定义在 Object 类中,因为锁属于对象。
- Java API 强制要求这样做,如果你不这么做,你的代码会抛出 IllegalMonitorStateException 异常。
- 还有一个原因是为了避免 wait 和 notify 之间产生竞态条件。
处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。
所以,我们不能写 if (condition)
而应该是 while (condition)
,特别是 CAS 竞争的时候。示例代码如下:
// The standard idiom for using the wait method synchronized (obj) { while (condition does not hold) { obj.wait(); // (Releases lock, and reacquires on wakeup) } ... // Perform action appropriate to condition } |
在指定的毫秒数内,让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响。让其他线程有机会继续执行,但它并不释放对象锁。也就是如果有synchronized
同步块,其他线程仍然不能访问共享数据。注意该方法要捕获异常。
比如有两个线程同时执行(没有 synchronized
),一个线程优先级为MAX_PRIORITY
,另一个为 MIN_PRIORITY
。
- 如果没有 sleep 方法,只有高优先级的线程执行完成后,低优先级的线程才能执行。但当高优先级的线程
#sleep(5000)
后,低优先级就有机会执行了。- 总之,sleep 方法,可以使低优先级的线程得到执行的机会,当然也可以让同优先级、高优先级的线程有执行的机会。
yield 方法和 sleep 方法类似,也不会释放“锁标志”,区别在于:
- 它没有参数,即 yield 方法只是使当前线程重新回到可执行状态,所以执行yield 的线程有可能在进入到可执行状态后马上又被执行。
- 另外 yield 方法只能使同优先级或者高优先级的线程得到执行机会,这也和 sleep 方法不同。
Thread 的非静态方法 join ,让一个线程 B “加入”到另外一个线程 A 的尾部。在线程 A 执行完毕之前,线程 B 不能工作。示例代码如下:
Thread t = new MyThread(); t.start(); t.join(); |
- 保证当前线程停止执行,直到该线程所加入的线程
t
完成为止。然而,如果它加入的线程t
没有存活,则当前线程不需要停止。
- sleep 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会。yield 方法只会给相同优先级或更高优先级的线程以运行的机会。
- 线程执行 sleep 方法后转入阻塞(blocked)状态,而执行 yield 方法后转入就绪(ready)状态。
- sleep 方法声明抛出 InterruptedException 异常,而 yield 方法没有声明任何异常。
- sleep 方法比 yield 方法(跟操作系统 CPU 调度相关)具有更好的可移植性。
Thread 类的 sleep 和 yield 方法,将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。
Thread#sleep(0)
方法,并非是真的要线程挂起 0 毫秒,意义在于这次调用 Thread#sleep(0)
方法,把当前线程确实的被冻结了一下,让其他线程有机会优先执行。Thread#sleep(0)
方法,是你的线程暂时放弃 CPU ,也就是释放一些未用的时间片给其他线程或进程使用,就相当于一个让位动作。
考点,就是 join 方法。
我们可以使用 Thread 类的 #join()
方法,来确保所有程序创建的线程在 main 方法退出前结束。
Thread#interrupt()
方法,用于中断线程。调用该方法的线程的状态为将被置为”中断”状态。
注意:线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出 InterruptedException 的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常。
Thread#interrupted()
静态方法,查询当前线程的中断状态,并且清除原状态。如果一个线程被中断了,第一次调用 #interrupted()
方法则返回 true
,第二次和后面的就返回 false
了。
// Thread.java public static boolean interrupted() { return currentThread().isInterrupted(true); // 清理 } private native boolean isInterrupted(boolean ClearInterrupted); |
Thread#isInterrupted()
方法,查询指定线程的中断状态,不会清除原状态。代码如下:
// Thread.java public boolean isInterrupted() { return isInterrupted(false); // 不清楚 } private native boolean isInterrupted(boolean ClearInterrupted); |
线程安全,是编程中的术语,指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
Servlet 不是线程安全的,Servlet 是单实例多线程的,当多个线程同时访问同一个方法,是不能保证共享变量的线程安全性的。
不是的,和 Servlet 类似的处理流程。
老生常谈的问题了,首先要说的是单例模式的线程安全意味着:某个类的实例在多线程环境下只会被创建一次出来。单例模式有很多种的写法,我总结一下:
线程同步,是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。
线程间的同步方法,大体可分为两类:用户模式和内核模式。顾名思义:
线程互斥,是指对于共享的进程系统资源,在各单个线程访问时的排它性。
在两个线程间共享变量,即可实现共享。
一般来说,共享变量要求变量本身是线程安全的,然后在线程内使用的时候,如果有对共享变量的复合操作,那么也得保证复合操作的线程安全性。
调用 Thread#holdsLock(Object obj)
静态方法,它返回 true
如果当且仅当当前线程拥有某个具体对象的锁。代码如下:
// Thread.java public static native boolean holdsLock(Object obj); |
从写代码的角度来说,两者的复杂度是相同的,因为同步代码与线程数量是相互独立的。
但是同步策略的选择依赖于线程的数量,因为越多的线程意味着更大的竞争,所以你需要利用同步技术,如锁分离,这要求更复杂的代码和专业知识。
ThreadLocal ,是 Java 里一种特殊的变量。每个线程都有一个 ThreadLocal 就是每个线程都拥有了自己独立的一个变量,竞争条件被彻底消除了。
它是为创建代价高昂的对象获取线程安全的好方法,比如你可以用 ThreadLocal 让 SimpleDateFormat 变成线程安全的,因为那个类创建代价高昂且每次调用都需要创建不同的实例所以不值得在局部范围使用它,如果为每个线程提供一个自己独有的变量拷贝,将大大提高效率。
所以,ThreadLocal 很适合实现线程级的单例。
InheritableThreadLocal 类,是 ThreadLocal 类的子类。ThreadLocal 中每个线程拥有它自己的值,与 ThreadLocal 不同的是,InheritableThreadLocal 允许一个线程以及该线程创建的所有子线程都可以访问它保存的值。
在多线程环境下,SimpleDateFormat 是线程安全的吗?
不是,非常不幸,DateFormat 的所有实现,包括 SimpleDateFormat 都不是线程安全的,因此你不应该在多线程序中使用,除非是在对外线程安全的环境中使用,如将 SimpleDateFormat 限制在 ThreadLocal 中。
如果你不这么做,在解析或者格式化日期的时候,可能会获取到一个不正确的结果。因此,从日期、时间处理的所有实践来说,我强力推荐 joda-time 库。
kill -3 [java pid]
不会在当前终端输出,它会输出到代码执行的或指定的地方去。比如, kill -3 tomcat pid
, 输出堆栈到 log 目录下。
Jstack [java pid]
这个比较简单,在当前终端显示,也可以重定向到指定文件中。
JVisualVM:Thread Dump
不做说明,打开 JVisualVM 后,都是界面操作,过程还是很简单的。
java.util.Timer
,是一个工具类,可以用于安排一个线程在未来的某个特定时间执行。Timer 类可以用安排一次性任务或者周期任务。
java.util.TimerTask
,是一个实现了 Runnable 接口的抽象类,我们需要去继承这个类来创建我们自己的定时任务并使用 Timer 去安排它的执行。
目前有开源的 Qurtz 可以用来创建定时任务。
1、给线程命名。
这样可以方便找 bug 或追踪。OrderProcessor、QuoteProcessor、TradeProcessor 这种名字比 Thread-1、Thread-2、Thread-3 好多了,给线程起一个和它要完成的任务相关的名字,所有的主要框架甚至JDK都遵循这个最佳实践。
2、最小化同步范围。
锁花费的代价高昂且上下文切换更耗费时间空间,试试最低限度的使用同步和锁,缩小临界区。因此相对于同步方法我更喜欢同步块,它给我拥有对锁的绝对控制权。
3、优先使用 volatile
,而不是 synchronized
。
4、尽可能使用更高层次的并发工具而非 wait 和 notify 方法来实现线程通信。
首先,CountDownLatch, Semaphore, CyclicBarrier 和 Exchanger 这些同步类简化了编码操作,而用 wait 和 notify 很难实现对复杂控制流的控制。
其次,这些类是由最好的企业编写和维护在后续的 JDK 中它们还会不断优化和完善,使用这些更高等级的同步工具你的程序可以不费吹灰之力获得优化。
5、优先使用并发容器,而非同步容器。
这是另外一个容易遵循且受益巨大的最佳实践,并发容器比同步容器的可扩展性更好,所以在并发编程时使用并发集合效果更好。如果下一次你需要用到 Map ,我们应该首先想到用 ConcurrentHashMap 类。
6、考虑使用线程池。
并发(Concurrency)和并行(Parallellism)是:
所以并发编程的目标是,充分的利用处理器的每一个核,以达到最高的处理性能。
如果数据将在线程间共享。例如正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。
当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。
当然,如果我们对效率没有特别大的要求,也不一定需要使用异步编程,因为它会带来编码的复杂性。总之,合适才是正确的。
本篇文章梳理了java并发相关高频的面试题,希望对你有帮助哦。
JAVA面试题传送门:
Java面试题之集合篇