你必须应该掌握的Java并发基础

前情引入

 在工作中,我们或多或少都会接触到与线程相关的东西,比如线程池、比如Runnable、Callable接口等等。如果这些你都没有接触过,至少Java程序的入口——main方法你一定有所了解。因为在Java程序的运行过程中,承载main方法的线程叫主线程。

 本篇中,我们不会做过多的代码演示,我们的目的仅仅是了解Java并发相关的概念,以及掌握几个相关的常见题目。

何为线程,何为进程

 在开始探究线程之前,我们应该先从应用程序的角度去理解一下什么叫进程。

 简单的说,进程是一段程序的一次执行过程,是系统运行程序的基本单位。在Java中,当我们启动main方法时就开启了一个jvm的进程,而这个main方法所在的线程就是这个进程中的其中一个线程。当我们在运行一段代码的时候,可以在任务管理器中看到相应的进程信息。
 譬如,在执行这样一段代码时。

    public class ClinitTest {
        public static void main(String[] args){
            Scanner scanner = new Scanner(System.in);
            while (scanner.hasNextLine()){
                int currentNumber = scanner.nextInt();
                // 以0结束输入
                if(currentNumber == 0){
                    break;
                }
            }
        }
    }

 上面的代码片段并没有什么实际意义,if()语块的作用仅仅是让程序不要一瞬即过,这样方便我们观察这个进程。
 通过jps命令可查看机器上有哪些Java的进程在运行,并且可看到这些进程的PID。如下图:


机器上运行中的Java进程

 我们可以通过进程的PID,在任务管理器中查找到这个进程,如下:


任务管理器中ClinitTest所在进程

 事实上,线程和进程很相似,但线程是比进程更小的执行单位。但我们可以这样去区分线程和进程:一个虚拟机可以产生多个进程,而一个进程可以持有多个线程。
 或者从main方法来谈这个问题。我们常说一切事物都是有因才有果,一个main方法想要被执行,就必须要有线程来承载这个方法;一个线程想要被执行,就需要有进程来承载;只不过jvm作为这个小世界的创造者,对于进程和线程就有统治权和调度权。
 除了上面的区别之外,进程拥有独立的运行地址空间,各个进程之间不会互相影响;而线程则不是,同类的多个线程可以共享同一个进程的地址空间。

在jvm中哪些区域是线程共享的

 在讨论这个问题之前,先让我们看一下jvm的内存模型(因jvm的版本不一样,会存在差异)。此处的jvm内存模型基于jdk1.8进行简要描述。如下图所示:


jvm内存模型(jdk1.8)

 按照该图的描述,多个线程可共享堆和元空间及直接内存的内容,而每个线程有自己的程序计数器、本地方法栈,虚拟机栈。

 如果你对jvm的内存模型并不了解,不知道每一个区域都存放了些什么样的数据,可在了解了相关内容后再回到这里。关于JVM内存模型的各个区域更加详细的介绍可参见:此处应插眼。
 如果你已经对这块内容熟悉,请思考这样一个问题:为什么程序计数器、本地方法栈和虚拟机栈都是线程私有的呢?能给出你的理解吗?

程序计数器为什么会被设计为线程私有

 程序计数器在运行时数据区域中只是一块很小的内存空间,每个线程有自己独立的程序计数器。关于为什么被设计为私有可以用一句话来概括性的回答:线程切换后找到正确的执行位置。

 Java虚拟机的多线程是通过线程轮流切换、享有处理器的时间片段的方式实现的。在任何一个时刻,处理器(对于多核处理器来说,这里指的是一个内核)只会执行一条线程中的指令。
 举例来说,假设一个处理器现在正在工作中,有两个线程分别为ThreadA和ThreadB,ThreadA里面有3条虚拟机字节码指令:A1、A2、A3、ThreadB里面有3条虚拟机字节码指令:B1、B2、B3。假设处理器的调度时间间隔是50ns,切换线程所需时间为5ns,从ThreadA开始执行。那么将出现下面这样的工作场景:

  1. 相对时间0~5ns之间:处理器切换线程为ThreadA,通过ThreadA的程序计数器中当前执行的虚拟机字节码指令的地址:比如A的指令地址;
  2. 5~55ns之间:执行ThreadA的指令,这段时间内,执行完了A2指令,准备执行A3指令;
  3. 55~60ns之间:保存ThreadA的A3指令的地址到程序计数器,切换线程让ThreadB享有处理器;
  4. 以此类推......

 虽然上面的例子只是一个简化后的处理流程,各个场景设定的严谨性也有待商榷,但它真实的反应了多线程的工作原理。从上面的内容我们可以明确的得知程序计数器有两个作用:第一个是为了记录当前线程究竟执行到哪一句指令了;第二个是为了在线程再次获得处理器时间片的时候能够准确的定位到应该执行的指令。

虚拟机栈为什么被设计为线程私有

 简单来说,每个Java方法在执行期间都会创建一个栈帧,这个栈帧用于存放局部变量表、操作数栈、常量池的引用等信息。每一个方法从被调用进入到执行完毕就对应着栈帧在虚拟机的栈中入栈和出栈的全过程。

 所以为了保证线程中的局部变量、操作数以及引用等不被其他线程访问到,虚拟机栈被设计为线程私有的。
 上面提到的局部变量表实际上存放了在编译器就可知的虚拟机基本数据类型(float、double、byte、short、int、long、char、boolean)和对象引用。

本地方法栈

 作用类似于虚拟机栈,不同的地方在于:虚拟机栈作用的对象是Java方法(可能用Java方法不太严谨,就是字节码所定义的方法);而本地方法栈作用对象是Native方法。
虚拟机规范中并没有明确指出Native方法应该用什么语言、什么数据结构来实现,所以虚拟机可根据自己的情况实现Native方法。比如用C/C++或者其他什么语言无所谓,只要虚拟机能保证这个本地方法能实现要求的功能就ok。

 关于堆的更多内容请参考jvm的内存模型那一章节,这里只关心如下几点即可:

  1. 堆空间是线程共享的;
  2. 所有的(忽略在栈上分配的情况,对这块感兴趣可参考方法逃逸分析的相关技术)对象实例都在这里分配内存;

 堆为什么没有被设计成线程私有?因为对象是在堆中分配的内存,很多情况下我们需要在不同的线程中访问同一个对象,所以堆是线程共享的区域。

元空间

 这一块空间存储的是元数据,也就是类加载后的信息。在jdk1.7之前,这部分数据被放在堆内(具体是堆内的方法区),在jdk1.8的时候被放到了直接内存的管辖范围之中,主要原因是大量的类加载信息经常会导致这一块区域溢出。

线程的生命周期

 《Java并发编程艺术》一书中有提到,Java线程在运行的生命周期中的某一时刻,只能处于线程所允许的其中一个状态。

线程的状态有哪些

 Java中线程的状态有初始状态、运行状态、阻塞状态、等待状态、超时等待状态和终止等待。详见下表:

状态名称 说明
NEW 初始状态,线程被构建,但是还没有调用start()方法
RUNNABLE 运行状态,操作系统中的'就绪'和'运行'统称为'运行中'
BLOCKED 阻塞状态,该状态说明线程正阻塞于锁
WAITING 等待状态,表示线程正处于等待状态,等待其他线程做出一些特定的动作(通知或者中断)
TIME_WAITING 超时等待状态,与WAITING的区别是,这个状态可以在指定的时间范围内自行返回
TERMINATED 终止状态,线程已被执行完毕
线程的状态如何转换

 线程的状态随着代码的执行而切换,线程状态的变迁如下图所示:(摘自《Java并发编程艺术》)


线程状态变迁图

 从上图可以看出,当创建一个线程后,线程处于NEW状态;调用线程的start()方法后,线程将处于READY(就绪)状态;系统调度(获得处理器的时间片)后,处于RUNNING(运行中)状态;当run()方法执行完毕或者异常退出时,处于TERMINATED状态。

 在Java中,READY和RUNNING状态被统称为RUNNABLE状态。当线程处于RUNNABLE时,可以和阻塞、等待和超时等待相互切换。上面的图已经有相关的描述,在此就不作过多的阐述了。

并发基础部分的常见题

 根据上面的内容,我们来看看这部分常常会被问到的几个问题,重点在于该怎么回答。

(一)并发与并行的区别

 答:并发是指多个任务交替使用处理器,在某一个确定的时刻只有一个任务在使用处理器(对多核CPU来说,这里的处理器指的是具体的某一个内核);而并行是指多个任务在任一时刻确实是同时在执行的。

(二)什么叫线程的上下文切换

 答:处理器在任一时刻只能处理一个任务,当这个任务的时间片用完之后,会保存这个任务的当前状态然后把处理器让给另外一个线程使用。从这个任务保存到再加载另外一个任务的过程就是一次上下文切换。

(三)说说sleep()方法和wait()方法区别和共同点

 答:关键记住一点,sleep方法没有释放锁,而wait方法释放了锁。因为sleep方法被调用后仍然持有锁,所以只能等方法执行完后,线程自动苏醒;而wait方法释放了锁,别的线程可以通过调用同一个对象上的notify()或者notifyAll()方法来唤醒它。

(四)为什么我们使用线程时调用的是start方法,为什么不是直接执行run()方法

 答:new一个Thread,线程进入NEW(初始)状态,想要被执行就需要调用start方法。start方法会执行相应的准备工作,并自动调用run()方法。如果直接调用run()方法会被当成当前线程的一个普通方法被执行。

 Java所使用的线程模型是内核级线程(KLT),start方法内部会调用一个本地的start0()方法,这个start0方法会完成线程的创建和初始化,这些都是操作系统帮我们完成的。而run方法并没有直接调用start0方法,所以这不是多线程的工作。关于这块,建议大家看一下Thread类中的start方法和run方法源码。


 本章我们已经对Java的并发有了一个初步的认识,在下一章中我们将对多线程所带来的问题进行分析,以及该如何去解决这些问题。


参考资料列表

1. 《Java并发编程艺术》——方腾飞,魏鹏,程晓明著


扩展区域

扩展区域主体

这是一个没有实现的扩展。

你可能感兴趣的:(你必须应该掌握的Java并发基础)