@Catyee
2021-08-03T11:46:02.000000Z
字数 9689
阅读 434
面试
线程是是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可以与同属一个进程的其他的线程共享进程所拥有的全部资源。
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。
此处指线程通用的实现方式,实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现), 使用用户线程加轻量级进程混合实现(N:M实现)。
使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由 操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调 度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视 为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个 内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1 的关系称为一对一的线程模型。
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程 在系统调用中被阻塞了,也不会影响整个进程继续工作。轻量级进程也具有它的局限性:首先,由于 是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调 用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个 轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈 空间),因此一个系统支持轻量级进程的数量是有限的
使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点。而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及是如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持 规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之 间1:N的关系称为一对多的线程模型。用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。一般的应用程序都不倾向使用用户线程。
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来 完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系。
Java线程如何实现并不受Java虚拟机规范的约束,这是一个与具体虚拟机相关的话题。从JDK1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理 器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。
线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行 完了之后,要主动通知系统切换到另外一个线程上去。其坏处显而易见:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。
Java使用的线程调度方式就是抢占式调度。
1:1的内核线程模型是如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。举个例子,HTTP请求可以直接与Servlet API中的一条处理线程绑定在一起,以“一对一服务”的方式处理由浏览器发来的信息,但是传统的Java Web服务器的线程池的容量通常在几十个到几百之间,所以当程序员把数以百万计的请求 往线程池里面灌时,系统即使能处理得过来,但其中的切换损耗也是非常大的。
内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。处理器要去执行线程A的程序代码时,并不是仅有代码程序就能跑得起来,程序是数据与代码的组合体,代码执行时还必须要有上下文数据的支撑。而这里说的“上下文”,以程序员的角度来看,是方法调用过程中的各种局部的变量与资源;以线程的角度来看,是方法的调用栈中存储的各类信息;而以操作系统和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。物理硬件的各种存储设备和寄存器是被操作系统内所有线程共享的资源,当中断发生,从线程A切换到线程B去执行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B 挂起时候的状态,这样线程B被重新激活后才能仿佛从来没有被挂起过。这种保护和恢复现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。
既然线程调度在用户态和内核态切换消耗更大,那可不可以由程序员自己在用户态下保护和恢复现场?这个时候用户线程就可以再次登场了。由于最初多数的用户线程是被设计成协同式调度,所以它有了一个别名——“协程”。
纤程是java中对有栈协程的一种实现方式。目前还未正式发布。
虽然说Java线程调度是系统自动完成的,但是我们仍然可以“建议”操作系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点——这项操作是通过设置线程优先级来完成的。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
不过,线程优先级并不是一项稳定的调节手段,很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。尽管现代的操作系统基本都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中线程有 2147483648(2的31次幂)种优先级,但Windows中就只有七种优先级。如果操作系统的优先级比Java线程优先级更多,那问题还比较好处理,中间留出一点空位就是了,但对于比Java线程优先级少的系统,就不得不出现几个线程优先级对应到同一个操作系统优先级的情况了。
线程优先级并不是一项稳定的调节手段,这不仅仅体现在某些操作系统上不同的优先级实际会变得相同这一点上,还有其他情况让我们不能过于依赖线程优先级:优先级可能会被系统自行改变。
从操作系统的角度来说,线程有5中状态:
这是操作系统层面的线程状态,Java语言层面则是定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。这些状态以枚举的形式直接定义在Thread类中:
sleep方法属于Thread类,sleep方法不会释放锁,只会阻塞线程,让出cpu给其它没有锁竞争关系的线程,睡眠时间足够会自动唤醒。
wait方法属于Object类,只能配合synchronized同步锁使用,wait方法会释放锁,然后阻塞线程,等待被唤醒,唤醒之后会重新尝试获取锁
yield方法也属于Thread类,yield方法也会暂停当前的执行,也不会释放锁资源,只是释放cpu,但是它不阻塞当前线程,而是让当前线程成为就绪状态,那么当前线程会和其它没有锁竞争关系的线程共同竞争CPU,有可能刚进入就绪状态又被执行。yield方法只能使同优先级或更高优先级的线程有执行的机会。
join方法也属于Thread类,主要作用是只有调用join方法的线程结束之后,当前线程才能够继续运行,所以join方法会阻塞当前线程。
首先思考一个问题,Vector、HashTable、ConcurrentHashTable一定是线程安全的吗?考虑如下场景:
// 在多个线程中运行以下代码片段
if (!vector.contains(i)) {
vector.add(i);
}
// 在多个线程中运行以下代码片段,cht定义为:Map<Integer, Integet> cht = new ConcurrentHashMap<>();
int value = ++cht.get(i);
cht.put(i, value)
// 或
cht.put(i, ++cht.get(i));
分析:片段1中虽然vector里面contains()和add()方法都加了synchornized关键字修饰,但是contains方法执行完同步锁就被释放了,这个时候可能被其它线程的add方法抢占到锁,向vetctor中添加了i,释放之后又被该线程的add方法抢占到锁,再次向Vector中添加了i。
片段2中++cht.get(i)其实是一个复合操作,需要先取出cht.get(i)的值,然后进行自增,即使get本身是线程安全的,但是复合操作也会造成线程不安全的情况,类似于多线程环境下的i++。
所以可以看到Vector、HashTable、ConcurrentHashMap这种通常意义上线程安全的类也并非绝对的线程安全。
为什么不使用Thread类的Stop()方法?
Stop()方法用于强行停止一个线程,但是已不是官方推荐使用的方法,主要是因为调用stop()方法后,可以在run方法体中任意一个位置退出,并且会释放所有同步锁,这样就有可能造成脏数据。
java中的线程中断机制:
1、Threa.interrupt()方法:向线程发送一个中断信号。
这个方法就是java中的线程中断机制,它其实只是一种协作机制,也就是说它并能真的中断线程的执行,实际上只是将一个中断标记设为true,含义就是告诉线程有这么一个中断的信号,但是否要响应这个中断请求需要线程自己去控制(也就是需要程序员自己处理)
2、Thread.interrupted()方法:判断是否有中断信号,如果有则返回true,并将中断标记改为false,也就是会清除中断信号。
3、Thread.isInterrupt()方法:判断是否有中断信号,如果有则返回true。
如何优雅停止一个线程:
1、在thread的方法体中合适的位置响应中断请求(通常是while循环或者Sleep中),如果需要中断线程,则可以直接调用Thread.interrupt()方法,发送一个中断信号,如果能执行到响应中断请求的代码中去,就可以优雅中断了。
2、用额外的标记来中断线程
多线程访问同一个共享变量的时候容易出现并发问题。一般可以用加锁的方式来保证访问共享变量的安全性,除了加锁有没有其它方式可以保证获取一个变量的线程安全性呢?
我们可以这样思考,之所以会出现线程安全问题是因为要访问的变量是共享的,那除了加锁之外最自然而然想到的就是:如果每个线程都操作自己的变量不就不存在线程安全问题了吗?ThreadLocal就是这种思路的一个实现。
ThreadLocal即线程本地变量。
Thread类中有一个ThreadLocalMap的属性,它用来存储我们的ThreadLocal对象,这就是ThreadLocal称为线程本地变量的原因,因为ThreadLocal对象就存储在线程内部。
// Thread的属性:
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
ThreadLocalMap的内部有一个数组,数组元素是一个个Entry对象,Entry结构如下所示:
// ThreadLocalMap的Entry结构
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
// key为ThreadLocal对象,是一个弱引用,value是待存储的值
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
所以ThreadLocalMap存储的就是key/value的键值对,它的key是弱引用的ThreadLocal对象,它的Value是ThreadLocal调用set方法设置的值,也就是我们用ThreadLocal保存的值。
我们在调用ThreadLocal对象的set()方法的时候,ThreadLocal会拿到当前线程的ThreadLocalMap,然后把自己作为key,把set()方法的参数也就是我们要存储的值作为value,放入到ThreadLocalMap中去:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
Thread类中的ThreadLocalMap属性默认是null,我们在调用ThreadLocal的set()和get()方法的时候会去检查当前线程的ThreadLocalMap是不是null,如果是null,则初始化出来,所以ThreadLocalMap是我们第一次调用ThreadLocal的set()或get()方法的时候才初始化出来的。
同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到的。(threadLocals中为当前调用线程对应的本地变量,所以二者自然是不能共享的),那如果我们想在子线程中获取到父线程的变量应该怎么办?这个时候就可以使用InheritableThreadLocal。
InheritableThreadLocal类继承了ThreadLocal类,并重写了childValue、getMap、createMap三个方法。其中createMap方法在被调用(当前线程调用set方法时得到的map为null的时候需要调用该方法)的时候,创建的是inheritableThreadLocals而不是threadLocals。
在创建一个子线程的时候,会判断当前父线程的inheritableThreadLocals是否为null,如果不为null,就会调用createInheritedMap()将父线程的inheritableThreadLocals中保存的key/value对也加入到子线程的inheritableThreadLocals中去。
为了缓解有可能发生的内存泄漏问题。举个例子,比如ThreadLocal是在某个方法内部创建出来的,局部变量有着对ThreadLocal对象的强引用,当退出这个方法的时候,栈帧被销毁,栈帧对ThreadLocal的强引用也就没有了。如果ThreadLocalMap的key也是强引用,那这个ThreadLocal对象是没法回收的。但是如果设置成弱引用,则会在gc的时候被回收掉。
ThreadLocal涉及到内存泄漏实际上指的是ThreadLocalMap中存储的key/value对没法回收同时又不再使用。ThreadLocalMap中对象的回收又涉及到两个地方,即ThreadLocalMap的key和value。
如果这个线程能够及时结束,其实就不存在内存泄漏的问题,线程结束之后,线程内的资源都会被回收。但是如果这个线程是线程池的核心线程,会一直存活,那就有可能发生内存泄漏。所以ThreadLocal内存泄漏的一个必要条件就是线程生命周期长,不会轻易的死亡。
如果我们把ThreadLocal设置为静态变量(即用static修饰,常用做法),那这个类已经有了这个ThreadLocal对象的强引用,只有这个类被卸载,这个ThreadLocal对象才会被回收。在这种情况下,即使ThreadLocalMap中key为弱引用也没什么用。key和value都不会被回收掉,如果线程一直存在,那这个key/value对也一直存在,这种情况只能手动调remove()方法来清除。
如果ThreadLocal不是静态变量,比如是在某个方法内部创建出来的,局部变量有着对ThreadLocal对象的强引用,当退出这个方法的时候,栈帧被销毁,,ThreadLocal的强引用也就没有了,只剩下ThreadLocalMap中key的弱引用,如果发生GC,这个ThreadLocal对象就会被回收掉,ThreadLocalMap中的key变成了null,但是value是强引用,回收不掉,就会出现key为null,value不为null的情况,value将会不可达,又回收不掉,就产生了内存泄漏。为了应对这种情况,在设计ThreadLocal的时候,设计为每当我们调用get()和set()方法都会清理key为null的键值对,这可以缓解内存泄漏的情况,但是不能根除,因为什么时候调用get()、set()方法是程序员决定的,有可能再也不调用。
所以防止内存泄漏最关键的在于良好的编程习惯,用完之后及时remove掉。
不一定线程安全,要看如何使用。ThreadLocal本身就是为了解决线程共享变量竞争的问题,如果让ThreadLocal作为一个共享变量(本末倒置的做法),那还是会有线程安全的问题。