阅读 853

Java并发——线程与Java线程

前言

对于大多数Java开发人员而言,在实际工作中很少遇到复杂的并发场景。最多可能也就用一下线程池来并发运行程序以提升执行效率。 虽然现状如此,但这并非意味着我们简单掌握并发的应用就可以了。在当今摩尔定律失效的情况下,并发处理成为了计算机发展的新动力,也成为了人类压榨CPU运算能力最有利的武器! 基于以上的认知,我认为从思想和底层基础的角度来系统学习Java并发知识会比零零散散的的学习和简单应用能够得到更高的收益。 本篇文章将系统介绍Java并发的基础条件——线程。

线程

首先,我们必须深刻认识到,在计算机系统里,并发不一定要依赖多线程!比如PHP中常见的多进程并发。但是Java语言实现并发是需要依赖于多线程的。

众所周知,线程比进程更加轻量级,它将进程的资源分配和执行调度分开,各个线程既可以共享进程资源,又可以独立调度。在Java语言中,线程是程序调度的最小单元(不过在未来如果Java成功引入纤程的话,这一点可能会被改变)。

站在计算机系统的角度来看,线程实现的方式主要有3种:使用内核线程实现(1:1)、使用线程实现(1:N)、混合实现(N:M)。

内核线程实现

内核线程是直接由操作系统内核控制的,内核通过调度器来完成内核线程的调度并负责将其映射到处理器上执行。内核态下的线程执行速度理论上是最高的,但是用户不会直接操作内核线程,而是通过内核线程的接口——轻量级进程来间接的使用内核线程。这种轻量级进程就是所谓的线程。

由于每一个线程都需要一个内核线程支持,因此内核线程和线程在数量是1:1的。故内核线程实现的方式也被称为1:1实现。

【优点】由于内核线程的支持,每一个线程都是一个独立的单元,因此就算某一个线程挂掉了,也不会导致整个进程挂掉。

【缺点】但是这种实现方式也存在局限性。由于是基于内核线程实现的,所以当涉及到线程的操作时(创建、运行、切换等)就涉及到系统调度,而系统调度则会导致用户态和内核态之间的来回切换,代价是比较昂贵的。

用户态和内核态切换的代价主要体现在响应中断、保护和恢复线程执行现场的成本。 程序的运行需要上下文的支撑,这里的上下文从不同的角度来看,代表的含义是不一样的。 从程序员的角度来看:方法调用的各种变量和资源; 从线程的角度来看:方法调用栈锁存储的各种信息; 从系统的角度来看:存储在寄存器、内存中的各个具体的数据。 当涉及到线程切换时,系统需要把当前线程执行的各种数据快照保存起来,这里面就涉及到数据在缓存、内存中来回拷贝等,之后还需要恢复即将执行的线程的上下文环境,因此它的代价是比较昂贵的!

用户线程实现

从广义的角度来看,只要不是内核线程都可以被看做是用户线程,因此轻量级进程也可以被看做是用户线程,但是轻量级进程始终是建立在内核线程上的,所以轻量级进程不具备通常意义上的用户线程所具备的优势。

而狭义的用户线程则是完全建立在用户空间的线程库,系统内核是无法感知到这些用户线程的存在。用户线程的创建、运行、调度等管理层面的操作都是在用户态下完成的,不需要内核的帮助。 【优点】由于不需要内核的帮助,这意味着如果操作得当,就可以不用切换到内核态,在这种情况下系统消耗相对低很多,可以更加轻松的实现更大规模的线程并发。现在,很多高性能数据库中的多线程就是基于用户线程实现的。这种进程与用户线程之间1:N的关系也被称为一对多实现模型。 【缺点】用户线程的优点在于不需要内核,其缺点也在于此。由于没有内核的帮助,对于线程的调度需要用户自行实现。而线程调度本身需要考虑的东西太多,因此对于用户而言实在是太复杂,搞不好就会导致线程阻塞从而引起系统崩溃。

在以前,Java、Ruby等语言也尝试使用用户线程,但最终都放弃了。近几年,以并发闻名于耳的编程语言Go、Erlang则使用的是用户线程。现在我们知道Go、ErLang语言天生支持高并发的本质原因了。

混合实现

混合实现中既有用户线程也有轻量级进程实现,他们的数量关系是不定的,因此也叫做N:M实现。在这种情况下,我们既可以享受用户线程带来的低消耗的好处,也能享受到内核线程在线程调度上的优势,在提升线程并发的同时,大大降低了线程阻塞的风险。

Java线程

Java线程的实现并不受《Java虚拟机规范》的约束,在不同的虚拟机上,其具体的实现是可以有差异。以Hotspot虚拟机为例,它的每一个线程都是直接映射到内核线程上的,因此它不会干预线程的调度、状态等。

Java线程调度

线程调度主要有两种方式协同式线程调度和抢占式线程调度。

【协同式线程调度】在协同式线程调度的情况下,线程的运行时间由线程本身决定,当线程运行完之后再主动通知系统切换到其他线程执行。【优点】这种实现简单高效,线程切换的对于线程而言是可见的。因此并不会出现线程同步的问题。【缺点】但这种方式也有缺点,线程本身的运行时间是不可控的,如果由于代码编写不当,导致线程阻塞,则将无法通知系统切换线程,使得程序永远阻塞在那里。

【抢占式线程调度】抢占式线程调度的情况下,线程的运行时间和切换将由系统控制。线程本身是没有任何办法的。比如在Java语言中,Thread::yield()方法可以主动让出执行时间,但是由于抢占式调度的特点,当前线程是否真正的让出了执行时间还是由系统说了算!

抢占式线程调度的缺点想必大多数人都应该知道,那就是同步问题。Java线程的调度方式是基于抢占式线程调度的。因此对于Java线程的探讨都是建立在抢占式的基础上。

虽然抢占式的情况下,线程无法决定其运行时间,但程序可以“建议”系统多分配一些时间给某些线程执行,这个功能就是线程优先级。线程优先级在不同的操作系统上具体的等级不一样,但功能都一致。因此虽然Java语言为线程定义了10级优先级,但对应到不同的操作系统上,可能会使某些级别的效果是一样的。

Java线程状态及状态切换

JAVA语言定义了6种线程状态,定义在java.lang.Thread.State。同一时间里一个线程的状态有且只能有1种状态,但是可以通过特定的方法改变其状态。

【新建】(NEW):创建后尚未启动的线程状态;

【运行】(RUNNABLE):包括操作系统线程状态中的Runing和Ready. 也就是说处于此状态的线程有可能正在执行,也有可能在等待操作系统分配执行时间;

【无限期等待】(WAITING):处于这种状态的线程CPU不会分配执行时间,并且需要其他线程显式的唤醒它. 有以下几种方式能够让线程变成这个状态:

1.没有设置Timeout参数的Object::wait();

2.没有设置Timeout参数的Thread::join();

3.LockSupport::park();

【有限期等待】(TIMED_WAITING):处于这种状态的线程将经过指定Timeout时间之后,变成RUNNABLE状态。有以下几种方式能够让线程变成这个状态:

1.设置le Timeout参数的Object::wait(long timeout);

2.设置了Timeout参数的Thread::join(long millis);

3.LockSupport::parkNanos(long nanos);

4.LockSupport::parkUntil(long deadline);

【阻塞】(Blocked):线程被阻塞。注意阻塞状态与等待状态是有本质区别的,阻塞状态发生在排他锁竞争的场景下,当线程没有竞争获取到锁时将会一直处于阻塞状态,直到持有锁的线程释放锁之后,被阻塞的线程将重新变成RUNNABLE状态,等待CPU调度并重新参与锁的竞争;而等待状态则是当前线程已经竞争到锁的情况下主动调用特定方法进入到等待状态,等待一段时间或者由其他线程显式唤醒再次变成RUNNABLE状态,等待CPU调度并重新参与锁的竞争。

【结束】(TERMINATED)线程执行完成后的状态。

通过以上分析,线程状态间相互转换就很容易描述出来了,为了更加直观的观察,我们把RUNNABLE状态细分为可运行状态和运行状态。它的相互转换关系如下:

线程状态流转
如果在并发场景下,Java线程状态的流转过程还有伴随着锁资源的流转。关于什么是锁以及锁资源的流转这些内容无法用几句话解释清楚,因此将会用单独的文章来解析。

通过本篇文章我们知道了操作系统实现线程的几种方式和各自的优缺点,以及主流虚拟机的线程实现方式,并掌握了Java线程状态和其流转过程。有了本篇知识的基本认知,再去探讨Java并发的内容才有意义。

文章分类
后端
文章标签