JAVA面试题锦集(多线程)
摘要:
进程是计算机中程序运行的基本单位;线程就是CPU调度和分配资源的基本单位。进程拥有独立的内存空间,进程间的内存资源也相互隔离的,是进程的私有资源;而线程之间强调的是一起配合工作,线程间是可以共享内存的。进程是整体的抽象概念,线程是分解了的具体概念;一个进程可以包含一个或多个线程,而一个线程只能属于一个进程
线程和进程
进程是计算机中程序运行的基本单位。比如我们运行一个程序,它占用多少资源,操作系统应怎么去分配,都是以进程为基本单位进行处理的。
线程就是CPU调度和分配资源的基本单位。在计算机中,CPU是运算和控制的核心,它可以解释并处理计算机指令,而进程这个东西以来的资源相对比较复杂,对于CPU来说,肯定无法整体调度,于是就将进程划分为具体的线程,然后通过CPU进行调度和分配。
**进程拥有独立的内存空间,进程间的内存资源也相互隔离的,是进程的私有资源;而线程之间强调的是一起配合工作,线程间是可以共享内存的。
进程是整体的抽象概念,线程是分解了的具体概念;一个进程可以包含一个或多个线程,而一个线程只能属于一个进程。**
并发与并行
并发是指同一个时间段内多个任务同时都在执行,强调的是同时间段同时执行;用编程话术来说,就是不同代码块在同一时间段内执行,可以同时间进行也可以交替执行。并发主要是研究CPU调度的,比如高并发时,发起的线程数量远高于CPU数量,如何调度各线程之间进行资源配置,使各个线程最终执行完毕,是主要议题。
并行是说在单位时间内多个任务同时在执行,强调的是同时间同时执行;编程话术说就是不同代码块同时间进行。并行在多个CPU的环境下,天然会发生。
我们说的多线程编程,其实一般也是针对并发概念而言的,准确的说是多线程并发编程。
Java内存模型
全名Java Memory Modle,简称 JMM,也就是我们中文所说的 Java内存模型,是用来描述或者规范访问内存变量的方式。
由于各种计算机,组成硬件和操作系统可能都有所不同,各种实现机制也是各有千秋,Java内存模型则做了统一的封装适配,来屏蔽这种平台差异,从而在各种系统平台都能达到一致的效果。当然,这也是我们常说,Java语言实现跨平台的重要机制之一。
Java内存规定了所有变量都存储在主内存(Main Memory)中,而每个线程又有自己的工作内存(Working Memory),也被称为本地内存,本地内存中保存了线程使用变量的在主内存中的拷贝副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存。
Java内存模型的工作方式有以下几种:
lock加锁:为了保证访问主内存变量的线程安全性,在访问前一般会加锁处理;
read读:从主内存中读取一个变量到工作内存;
load加载:把read读到的变量加载到工作内存的变量副本中;
use使用:此时线程可以使用其工作内存中的变量了;
assign赋值:将处理后的变量赋值给工作内存中的变量;
store存储:将工作内存中的变量存储到主内存中,以新建new 一个新变量的方式存储;
write写:将store存在的新变量的引用赋值给被处理的变量;
unload解锁:所有的工作做完,最后解锁释放资源。
多线程三大特性
在多线程编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。由这几个问题,可以总结出Java内存模型的三大特性:
原子性(Atomicity)
这里的原子性跟数据库事务中的原子性类似,一个或多个操作要么全执行成功要么全不执行。
我们在Java语言中可以通过锁、synchronized来确保多线程环境的原子性。
可见性(Visibility)
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
有序性(Ordering)
线程内的所有操作都是有序的,既程序执行的顺序按照代码的先后顺序执行。
说有序性之前,我们先了解下指令重排序(Instruction Reorder)的概念。指令重排序,是指处理器为了提高程序运行效率,在执行一段代码是,可能会对代码执行顺序进行优化;这个优化调整,可能会另执行代码的顺序发生调整,这个过程虽不保证程序中各个语句的执行先后顺序和代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,也就是说,如果代码之间有逻辑依赖上的顺序,这个是不受影响的。
当然,上面这些都是对于单个线程来说的。
下面我们看一段代码:
//线程A
Config config = Tools.loadConfig(); //语句1
boolean inited = true; //语句2
//线程B
while(!inited ){ //语块3
sleep();
}
doWithConfig(config); //语句4
这段代码,我们可以看到语句1和语句2,没有数据依赖性,在多线程中,如果线程A在执行过程中先执行了语句二,而恰好线程B刚好进来,这就可能使B产生语句1已经执行完了的错觉,于是就跳出while循环,去执行 doWithConfig(config) 方法,这时候 config 并没有获取到值,就会导致程序出错。
我们可以得出这样的结论:指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
在Java多线程里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
线程的状态
在整个生命周期中,线程的状态有:新建状态(New)、就绪状态(Runable)、运行状态(Running)、阻塞状态(Blocked)、死亡状态(Dead)这几种状态。
接下来我们纠结和代码,看看这几种状态。
一,新建状态(New)
我们写这样一行代码:Thread thread = new Thread();
就这样,我们创建了一个线程;这种刚被初始的线程,就是新建状态;
二,就绪状态(可运行状态,Runable)
线程被创建之后,暂时还是无法使用的,这时候我们还可以给线程做一些属性设定,例如:
// 设置类加载器
thread.setContextClassLoader(System.class.getClassLoader());
// 设置线程名称
thread.setName("myThread");
// 是否为守护线程
thread.setDaemon(false);
// 设置线程优先级
thread.setPriority(5);
设置完属性,我们需要调用它的 start() 方法来开启线程。
开启线程不代表线程就一定执行,只是说可以运行了,他还需要CPU做资源调度,才能正式运行,这个状态就是可运行状态;
三,运行状态(Running)
CPU完成资源调度,咱的线程就正式执行了,这个状态即被称为运行状态;
四,阻塞状态(Blocked)
当多个线程争夺CPU资源时,同时只能有一个线程执行,为保证访问资源的线程安全,同时刻只能有一个线程进入 synchronized 同步块,而其他未获得资源访问权的线程将进入阻塞状态。
线程的阻塞又有以下几种情况:
(1)等待阻塞:通过调用线程的wait()方法,让线程等待某工作的完成。
(2)同步阻塞:线程在获取synchronized同步锁失败(因为锁被其它线程占用),它会进入同步阻塞状态。
(3)其他阻塞:通过调用线程的sleep()方法或join()方法,或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、或join()等待线程终止或者超时、或者I/O处理完毕时,阻塞的线程也就等到了资源,重新转入就绪状态。
五,死亡状态(Dead)
线程执行完毕或出现异常提前结束了run() 方法,当前线程生命周期结束,即死亡状态。
好了,关于线程的周期,我们回顾完毕。
synchronized 的内存语义
synchronized 这个内存语义是用来解决共享变量内存可见性问题。
进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出时synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。
说通俗点就是原本线程间用的是工作内存,各用各的,关键字synchronized的作用是让线程工作时先去主内存取值,结束时还要把值刷回去,由于这种工作原理,会造成一些上下文切换的开销,并可能会生独占锁,降低并发性。
Volatile的内存语义
volatile关键字可以确保对一个变量的更新对其他线程马上可见。
当一个变量声明被volatile修饰时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
volatile的内存语义和synchronized有相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)。由于这种机制,使用volatile不能保证变量的原子性。