Skip to content

Java 中的线程模型

Linux 中的内核态和用户态

  1. 操作系统需要两种 CPU 状态:

    • 内核态(Kernel Mode):运行操作系统程序
    • 用户态(User Mode):运行用户程序
  2. 指令划分:

    • 特权指令:只能由操作系统使用、用户程序不能使用的指令。 举例:启动 I/O 内存清零 修改程序状态字 设置时钟 允许/禁止终端 停机
    • 非特权指令:用户程序可以使用的指令。 举例:控制转移 算数运算 取数指令 访管指令(使用户程序从用户态陷入内核态)
  3. 特权级别:

    • 特权环:R0、R1、R2 和 R3
    • R0 相当于内核态,R3 相当于用户态;
    • 不同级别能够运行不同的指令集合;
  4. CPU 状态之间的转换:

    • 用户态--->内核态:唯一途径是通过中断、异常、陷入机制(访管指令)
    • 内核态--->用户态:设置程序状态字 PSW
  5. 内核态与用户态的区别

    • 内核态与用户态是操作系统的两种运行级别,当程序运行在 3 级特权级上时,就可以称之为运行在用户态。因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;
    • 当程序运行在 0 级特权级上时,就可以称之为运行在内核态。
    • 运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态。
    • 这两种状态的主要差别是:
      • 处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理机是可被抢占的;
      • 而处于核心态执行中的进程,则能访问所有的内存空间和对象,且所占有的处理机是不允许被抢占的。
  6. 通常来说,以下三种情况会导致用户态到内核态的切换

    • 系统调用
      • 这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。比如前例中 fork()实际上就是执行了一个创建新进程的系统调用。
      • 而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如 Linux 的 int 80h 中断。
    • 异常
      • 当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
    • 外围设备的中断
      • 当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,
      • 如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

这 3 种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

java 中的线程模型

在 Java 中,基本我们说的线程(Thread)实际上应该叫作“用户线程”,而对应到操作系统,还有另外一种线程叫作“内核线程”。

用户线程和内核线程之间必然存在某种关系,多对一模型、一对一模型和多对多模型

多对一线程模型

多个用户线程对应到同一个内核线程上,线程的创建、调度、同步的所有细节全部由进程的用户空间线程库来处理。

  • 优点:
    • 用户线程的很多操作对内核来说都是透明的,不需要用户态和内核态的频繁切换,使线程的创建、调度、同步等非常快;
  • 缺点:
    • 由于多个用户线程对应到同一个内核线程,如果其中一个用户线程阻塞,那么该其他用户线程也无法执行;
    • 内核并不知道用户态有哪些线程,无法像内核线程一样实现较完整的调度、优先级等;

一对一模型

即一个用户线程对应一个内核线程,内核负责每个线程的调度

  • 优点:
    • (比如 JVM 几乎把所有对线程的操作都交给了内核)实现线程模型的容器(jvm)简单,所以我们经常听到在 java 中使用线程一定要慎重就是这个原因;
  • 缺点:
    • 对用户线程的大部分操作都会映射到内核线程上,引起用户态和内核态的频繁切换;
    • 内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响;

多对多模型

太复杂了,不需要了解

Java 线程切换为什么成本会高?为什么不推荐使用 synchronized 关键词?

由于 Java 线程的切换和 synchronized 都是需要用户态到内核态转换的,这个切换非常消耗资源。

应用程序的执行必须依托于内核提供的资源,包括 CPU 资源、存储资源、I/O 资源等。为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。因此,如果一个程序需要从用户态进入内核态,那么它必须执行系统调用语句。只要发生了系统调用,就会有从用户态到内核态的转换。

Java 中的线程模型为一对一模型(即一个用户线程对应一个内核线程,内核负责每个线程的调度),JVM 几乎把所有对线程的操作都交给了内核,因此肯定会发生用户态到内核态的转换。 synchronized 标注的函数会加一个读写锁,加锁解锁也会有内核态与用户态的转换。

当程序中有系统调用语句,程序执行到系统调用时,首先使用类似 int 80H 的软中断指令,保存现场,去的系统调用号,在内核态执行,然后恢复现场,每个进程都会有两个栈,一个内核态栈和一个用户态栈。当执行 int 中断执行时就会由用户态,栈转向内核栈。系统调用时需要进行栈的切换。而且内核代码对用户不信任,需要进行额外的检查。系统调用的返回过程有很多额外工作,比如检查是否需要调度等。

关于公平锁和非公平锁的加锁流程和区别

image

Wait, Notify 和 Sleep 的区别

相同:线程的状态相同;都是阻塞状态

区别:

  1. wait 是 Object 的方法;任何对象都可以直接调用;sleep 是 Thread 的静态方法
  2. wait 必须配合 synchronized 关键字一起使用;如果一个对象没有获取到锁直接调用 wait 会异常;sleep 则不需要
  3. wait 可以通过 notify 主动唤醒;sleep 只能通过打断主动叫醒
  4. wait 会释放锁、sleep 在阻塞的阶段是不会释放锁的

JMM java 内存模型

关于 CPU 内存

CPU、内存、I/O 设备都在不断迭代,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为:CPU 快于内存快于 I/O 设备,程序里大部分语句都要访问内存,有些还要访问 I/O 所以程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:CPU 增加了缓存,以均衡与内存的速度差异;操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

可见性问题

单核电脑,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的 一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

多核时代,每个 core 都有自己的 cache,这时 core 的缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 core 上执行时,这些线程操作的是不同的 core 缓存。比如,线程 A 操作的是 core1 上的缓存,而线程 B 操作的是 core2 上的缓存,这个时候线程 A 对变量 x 的操作如果没有及时写回主存,那么对于线程 B 而言就不具备可见性了。

线程切换——编译优化

重排序可以提高处理的速度。但是编译优化,指令重排会导致有序性问题的发生。参考下面这一章节:为什么定义 Java 内存模型?

JAVA 内存模型

你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。合理的方案应该是按需禁用缓存以及编译优化。

那么,如何做到“按需禁用”呢?

对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。

为什么定义 Java 内存模型?

现代计算机体系大部是采用的对称多处理器的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行。在 Java 中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序。因此 Java 语言规范引入了 Java 内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。

Java 内存模型涉及的几个关键词:锁、volatile 字段、final 修饰符与对象的安全发布。其中:

  • 第一是锁,锁操作是具备 happens-before 关系的,解锁操作 happens-before 之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM 需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
  • 第二是 volatile 字段,volatile 字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile 字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。
  • 第三是 final 修饰符,final 修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含 final 修饰的实例字段时,其他线程能够看到已经初始化的 final 实例字段,这是安全的。

关于 java 的 happens-before 原则

  1. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作 happens-before 书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构;但是这个规则是对结果负责。
  2. 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序
  3. volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的"后 面"同样是指时间上的先后顺序
  4. 线程启动规则:Thread 对象的 start()方法先行发生于此线程的每一个动作
  5. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行
  6. Happens-Before 具备传递性