Java并发编程:1-线程和进程

前言:

  • 本模块是在下学习Java并发的一些记录和思考,如有不正之处,请多多谅解并欢迎指正。
  • 开头会抛出几道常见面试题,引出本篇的内容。
  • 每一个问题都有属于你的答案。
  • 若是你有想法或建议,能够评论或者私信我 : ) wangjie2yd@gmail.com

面试问题

Q : 线程和进程的区别?
Q : 多线程的优缺点?java

1.进程

1.1 进程的由来

  进程的由来涉及到操做系统的发展历史,早期的计算机只能用来解决数学计算问题。由于不少大量的计算经过人力去完成是很耗时间和人力成本的。最初的计算机,只能接受一些特定指令,用户输入一个指令,计算机就作一个操做,假设用户输入指令和读取数据须要10s,计算可能只须要0.01s,计算机绝大多数都处于等待用户输入的状态,显然这样效率很低。 git

  那么能不能把一系列须要输入的指令都提早写好,造成一个清单,而后一次性交给计算机,这样计算机就能够不断读取指令来进行相应的操做,因而,批处理操做系统就诞生了。这样就提升了任务处理的便捷性,减小用户输入指令时间。面试

  可是仍然存在一个问题:数据读取(I/O操做)所须要的CPU资源很是少。大部分工做是分派给DMA(Direct Memory Access)直接内存完成的。在DMA读取数据的时候,CPU是空闲的,只能等待当前的任务读取完数据才能继续执行,这样就白白浪费了CPU资源,因而人们在想,可否让CPU在等待A任务读取数据期间,去执行B任务,当A任务读取完后,暂停B任务,继续执行A任务?编程

能够打开Windows的任务管理器,复制一个大文件,你会发现,磁盘利用率会持续增大,而CPU的利用率则会稍微增大一些,而后恢复正常,这个变化过程就是CPU给DMA分派任务

  这样就有一个新的问题,原来每次都是一个程序在计算机里面运行,也就说内存中始终只有一个程序的运行数据。而若是想要任务A执行I/O操做的时候,让任务B去执行,必然内存中要装入多个程序,那么如何处理呢?多个程序使用的数据如何进行辨别呢?而且当一个程序运行暂停后,后面如何恢复到它以前执行的状态呢?windows

  这个时候人们就发明了进程,用进程来对应一个程序,每一个进程对应必定的内存地址空间,而且只能使用它本身的内存空间,各个进程间互不干扰。而且进程保存了程序每一个时刻的运行状态,这样就为进程切换提供了可能。当进程暂停时,它会保存当前进程的状态(好比进程标识、进程的使用的资源等),在下一次从新切换回来时,便根据以前保存的状态进行恢复,而后继续执行。浏览器

1.2 并行和并发

  进程的出现,使得操做系统的并发成为可能,注意这里说的是 并发 而不是 并行 。这二者在概念上大相径庭。
并发: 从宏观上看起来同一时间段,多个任务都在执行,但具体的某一时间点,只有一个任务在使用CPU(针对单核CPU来讲),cpu把这个时间段分片给多个任务,因为整个时间段很小,因此咱们感受CPU好像在同时运行这些任务。
  并行: 同一时间点,多个任务同时执行,单核CPU没法作到,而多核CPU能够。安全

1.3 从应用层面理解进程

  进程是程序的一次执行过程,是操做系统分配资源的基本单位。 服务器

  在现代的操做系统好比 Windows、Linux、UNIX、Mac OS X等,都是支持多任务的操做系统。意味着操做系统能够同时运行多个任务,不管你的CPU是单核单线程仍是多核多线程,你均可以一边听歌,一边玩游戏。这个时候至少有2个任务(能够理解为2个进程,但实际可能会多于2个进程,例如Chrome浏览器,你每打开一个标签页,Chrome浏览器应用都会建立一个新的进程)同时在运行。还有不少任务悄悄地在后台同时运行着,只是桌面上没有显示而已。这就是多任务的并发。多线程

  固然如今的CPU大多都是多核多线程,有的还支持超线程技术(将一个物理处理器在软件层变成两个逻辑处理器),使一个CPU核心能够并行两个线程,但系统所运行的任务数远远多于CPU的核心数,因此,操做系统也会自动把不少任务轮流调度到每一个核心上执行,因此并发和并行在系统运行时是一直存在的。并发

打开Windows任务管理器,能够看到操做系统上运行的任务,以下:
1-进程展现.jpg

  Google Chrome(10),10就表明着这个任务下有10个进程
  后台进程(98),表明着有98个后台进程在默默运行着

2.线程

2.1 线程的由来

  进程的出现,解决了操做系统的并发问题,使得操做系统的性能获得了大大的提高。有新的问题出现了,由于一个进程在一个时间段内只能作一件事情,若是一个进程有多个子任务,只能逐个地去执行这些子任务。好比对于一个监控系统来讲,它不只要把图像数据显示在画面上,还要与服务端进行通讯获取图像数据,还要处理人们的交互操做。若是某一个时刻该系统正在与服务器通讯获取图像数据,而用户又在监控系统上点击了某个按钮,那么该系统就要等待获取完图像数据以后才能处理用户的操做,若是获取图像数据须要耗费10s,那么用户就只有一直在等待。显然,对于这样的系统,人们是没法知足的。

  那么可不能够将这些子任务分开执行呢?即在系统获取图像数据的同时,若是用户点击了某个按钮,则会暂停获取图像数据,而先去响应用户的操做(由于用户的操做每每执行时间很短),在处理完用户操做以后,再继续获取图像数据。人们就发明了线程,让一个线程去执行一个子任务,这样一个进程就包括了多个线程,每一个线程负责一个独立的子任务,这样在用户点击按钮的时候,就能够暂停获取图像数据的线程,让UI线程响应用户的操做,响应完以后再切换回来,让获取图像的线程获得CPU资源。从而让用户感受系统是同时在作多件事情的,知足了用户对实时性的要求。

  换句话说,进程让操做系统的并发性成为可能,而线程让进程的内部并发成为可能。可是要注意,一个进程虽然包括多个线程,可是这些线程是共同享有进程占有的资源和地址空间的。进程是操做系统进行资源分配的基本单位,而线程是操做系统进行调度的执行单位。

2.2 Java中的线程

  Java语言内置了多线程支持,一个Java程序其实是一个JVM进程(也能够称为JVM实例),通常来讲名字默认为java.exe或者javaw.exe(windows下能够经过任务管理器查看)。

  Java采用的是单线程编程模型,JVM进程用一个主线程来执行main()方法。 main方法所在的主线程只是其中的一个线程,JVM进程在启动时,同时会建立不少其余的线程。

咱们能够经过 JMX 来看一下一个普通的 Java 程序有哪些线程,代码以下:

public class MultiThread {
    public static void main(String[] args) {
        // 获取 Java 线程管理 MXBean
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        // 不须要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
        ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
        // 遍历线程信息,仅打印线程 ID 和线程名称信息
        for (ThreadInfo threadInfo : threadInfos) {
            System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
        }
    }
}
上述程序输出以下(输出内容可能不一样,不用太纠结下面每一个线程的做用,只用知道 main 线程执行 main 方法便可):

[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序入口
从上面的输出内容能够看出:一个 Java 程序的运行是 main 线程和多个其余线程同时运行。

  在main()方法内部,咱们还能够启动多个本身的线程。这就是多线程的由来。同类的多个线程共享进程的堆和方法区资源,但每一个线程有本身的程序计数器、虚拟机栈和本地方法栈。因此系统在产生一个线程,或是在各个线程之间做切换工做时,负担要比进程小得多,也正由于如此,线程也被称为轻量级进程。

2-JVM运行时数据区域.png

  堆和方法区:堆和方法区是全部线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新建立的对象 (全部对象都在这里分配内存)和成员变量,方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  程序计数器:在多线程的状况下,经过线程私有的程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候可以知道该线程上次运行到哪儿了,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。

  虚拟机栈: 每一个 Java 方法在执行的同时会建立一个栈帧用于存储局部变量表、操做数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

  本地方法栈: 和虚拟机栈所发挥的做用很是类似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
因此,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

3.多线程的优缺点

3.1 多线程的优点

  • 发挥多处理器的强大能力,提升资源利用率

  当下,单核CPU的经过提升时钟频率来提高性能已经愈来愈难,既然单核CPU的性能已经很难提高,那不妨尝试经过提高CPU核心的数量,处理器厂商在单个芯片上放置多个处理器核,以横向扩展来提高计算机的总体性能,再日后可能就是增长CPU的数量以及优化CPU之间的协做。

  操做系统的基本调度单位是线程,多核处理器的出现,使得同一个程序的多个线程能够被调度到多个 CPU 上同时运行。所以,多线程的程序能够经过提升处理器资源的利用率来提高系统的吞吐率。其实,多线程程序也有助于在单处理器系统上得到更高的吞吐率,若是程序的一个线程在等待 I/O 操做的完成,另外一个线程能够继续运行,使程序可以在 I/O 阻塞期间继续运行。(关于阻塞的理解,后边会谈到)

  • 解耦程序开发,程序设计更简单

  若是在程序中只包含一种类型的任务,那么比包含多种不一样类型任务的程序要更容易编写,错误更少,也更容易测试。

  在程序中,若是咱们为每种类型的任务都分配一个专门的线程,那么能够造成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节,交替执行的操做,异步 I/O 以及资源等待等问题分离开来。经过使用线程,能够将复杂而且异步的工做流进一步分解为一组简单而且同步的工做流,每一个工做流在一个单独的线程中运行,并在特定的同步位置进行交互。

  Servlet和RMI(Remote Method Invocation) 框架就是一个很好的例子。框架负责解决一些细节问题,包括请求管理、线程建立、负载均衡等,并在正确的时刻将请求分发给正确的应用程序组件(对应的一个具体Servlet)。编写 Servlet 的开发人员不须要了解有多少请求在同一时刻被处理,也不须要了解套接字的输入(出)流是否被阻塞。当调用 Servlet 的 service 方法来响应 Web请求时,能够以同步方式来处理这个请求,就好像它是一个单线程的程序。这种方式简化了组件的开发,大大下降框架学习门槛。

  • 异步化事件处理,程序响应更快

  同步与异步是关于指令执行顺序的。
  同步是指代码调用IO操做时,必须等待IO操做完成才返回的调用方式。
  异步是指代码调用IO操做时,没必要等IO操做完成就返回的调用方式。
  异步则须要多线程,多CPU或者非阻塞IO的支持。
  借鉴一个例子,来理解同步和异步:

  同步:你妈让你烧壶水,因而你一直在旁边等着水开 这个时候你什么都不能作
  异步:仍是烧一壶水,你找一个小A来帮你盯着,你就能够去作别的事了
  在这个场景下,你是负责处理请求的线程,小A就是一个新的线程来执行烧水的任务

3.2 多线程带来的风险

  • 数据安全性问题

  在线程安全性的定义中,最核心的概念就是正确性。当多个线程访问某个类时,无论运行时环境采用何种调度方式或者这些线程将如何交替执行,而且在主调代码中不须要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。

// 线程不安全类示例:
@NotThreadSafe 
public class UnsafeSequence { 
    private int value;

    /** Returns a unique value. */
    public int getNext() { 
        return value++; 
    } 
}

虽然 递增运算 “value++” 看上去是单个操做,但实际上它包含三个独立的操做:读取 value, 将 value 加 1,并将计算结果写入 value。

3-线程不安全示例.jpg

开始value的值为9,A,B 两个线程都执行getNext()方法,预期的返回值应该是11,由于执行了两次++操做。
可是在多线程的环境下,A线程从进程读取 value=9后,发生了线程切换,B线程开始执行,而且也读到了value=9
A线程开始执行,此处value=9已经记录到A线程内部,它把线程内部的9进行+1,变成了10,B线程也进行了一样的操做
此时A线程继续执行,在执行value=10前,进程里堆中的value仍是9,执行value=10后,堆中的value就变成10
线程B执行最后的操做,将堆中的value也修改成10,但其实这个时候value已经被A线程修改成10。
这样AB两个线程的getNext()都执行完了,可是堆中的value并非预期的11,而是10,这就是线程安全问题。

  • 跃性问题

  活跃性问题的关注目标在于 某件正确的事情最终会发生,我片面理解为程序会不会卡住,从而没法执行后边的内容,例如你代码中无心形成死循环,从而使循环以后的代码没法获得执行。
  线程将带来一些其余活跃性问题包括死锁、活锁和饥饿。这些问题都会让你的程序卡住,没法进行下去。

下面简单描述一下这三个问题,在后边的篇章会有具体的内容。

死锁:你要上厕所,但里面有人,并且把厕所门从里边锁住了,若是他一直不出来,你一直等待,这样就发生死锁了。

活锁:你走在路上,迎面走来一我的,你想给他让路,结果他也想给你让路,你俩都作了这个让路操做后,发现他仍是在
你面前,因而你又让路,他的想法也和你同样。因而乎,你俩就处在一直给对方让路的操做中,谁也没法经过,这个就是活锁问题。

饥饿:线程获取到CPU的时间分片才能执行,CPU分配时间分片是随机的,哪一个线程抢到哪一个就运行,若是这个线程运气比较差,永远抢不到。这个就是饥饿问题。

  • 性能问题

  性能问题关注的是:正确的事情可以尽快发生。性能问题包括多个方面,例如响应不灵敏,吞吐率太低,资源消耗太高等。在多线程程序中,当线程调度器挂起活跃线程并转而运行另外一个线程时,就会频繁出现上下文切换操做(Context Switch),这种操做会致使 CPU 时间更多的花在线程调度上而非线程的运行上。

上下文切换操做
  多线程编程中通常线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能获得有效执行,CPU 采起的策略是为每一个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会从新处于就绪状态让给其余线程使用,这个过程就属于一次上下文切换。
  归纳来讲就是:当前任务在执行完 CPU 时间片切换到另外一个任务以前会先保存本身的状态,以便下次再切换会这个任务时,能够再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换一般是计算密集型的。也就是说,它须要至关可观的处理器时间,在每秒几十上百次的切换中,每次切换都须要纳秒量级的时间。
  因此,上下文切换对系统来讲意味着消耗大量的 CPU 时间,事实上,多是操做系统中时间消耗最大的操做。
Linux 相比与其余操做系统(包括其余类 Unix 系统)有不少的优势,其中有一项就是,其上下文切换和模式切换的时间消耗很是少。

Reference

  《Java 并发编程实战》
  《Java 编程思想(第4版)》
  https://blog.csdn.net/justlov...
  https://snailclimb.gitee.io/j...

感谢阅读!
万丈高楼平地起,勿在浮沙筑高台。
与君共勉

相关文章
相关标签/搜索