跳至主要內容

JUC1 - 基本介绍1

codejavajuc约 2336 字大约 8 分钟

JUC1

进程与线程

进程是操作系统分配资源的最小单位,每个进程都有自己独立的内存地址空间。一个进程崩溃通常不会直接影响到其他进程(除非耗尽了系统资源)

线程是进程中的独立执行单元,是CPU分配调度的最小单位, 是操作系统真正放在 CPU 核心上去执行的实体

alt text
alt text

协程

协程被视为比线程更轻量级的并发单元,可以在单线程中实现并发执行,由我们开发者显式调度。

协程是在用户态进行调度的,避免了线程切换时的内核态开销

线程切换

线程 A 运行 → 系统调用/中断 → 切换到内核态 → 保存 A 的上下文 → 调度线程 B → 恢复 B 的上下文 → 返回用户态运行 B

JAVA里的线程

Java 底层会调用 pthread_create 来创建线程,所以本质上 java 程序创建的线程,就是和操作系统线程是一样的,是 1 对 1 的线程模型

alt text
alt text

JAVA中线程通信

原则上可以通过消息传递和共享内存两种方法来实现

Java 采用的是共享内存的并发模型

这个模型被称为 Java 内存模型,简写为 JMM,它决定了一个线程对共享变量的写入,何时对另外一个线程可见

当然了,本地内存是 JMM 的一个抽象概念,并不真实存在。

用一句话来概括就是:共享变量存储在主内存中,每个线程的私有本地内存,存储的是这个共享变量的副本

alt text
alt text

线程 A 与线程 B 之间如要通信,需要要经历 2 个步骤:

  • 线程 A 把本地内存 A 中的共享变量副本刷新到主内存中。
  • 线程 B 到主内存中读取线程 A 刷新过的共享变量,再同步到自己的共享变量副本中

并发与并行

顺序执行

顺序执行其实很好理解,就是我们依次去将这些任务完成了

并发执行

并发执行也是我们同一时间只能处理一个任务,但是我们可以每个任务轮着做(时间片轮转)

只要我们单次处理分配的时间足够的短,在宏观看来,就是三个任务在同时进行。

而我们Java中的线程,正是这种机制,当我们需要同时处理上百个上千个任务时,很明显CPU的数量是不可能赶得上我们的线程数的,所以说这时就要求我们的程序有良好的并发性能,来应对同一时间大量的任务处理。学习Java并发编程,能够让我们在以后的实际场景中,知道该如何应对高并发的情况

并行执行

并行执行就突破了同一时间只能处理一个任务的限制,我们同一时间可以做多个任务

比如我们要进行一些排序操作,就可以用到并行计算,只需要等待所有子任务完成,最后将结果汇总即可。包括分布式计算模型MapReduce,也是采用的并行计算思路

线程安全

如果一段代码块或者一个方法被多个线程同时执行,还能够正确地处理共享数据,那么这段代码块或者这个方法就是线程安全的

三个特性:

原子性

一个操作要么完全执行,要么完全不执行,不会出现中间状态

可以通过同步关键字 synchronized 或原子操作,如 AtomicInteger 来保证原子性

可见性

当一个线程修改了共享变量,其他线程能够立即看到变化

可以通过 volatile 关键字来保证可见性

有序性

要确保线程不会因为死锁、饥饿、活锁等问题导致无法继续执行

锁机制

通过 synchronized 关键字,可以实现加锁功能,这样就能够很好地解决线程之间争抢资源的情况

使用 synchronized,一定是和某个对象相关联的,比如我们要对某一段代码加锁,那么我们就需要提供一个对象来作为锁本身:

public static void main(String[] args) {
  synchronized (Main.class) {
      //这里使用的是Main类的Class对象作为锁
  }
}

对应的字节码为:

 0 ldc #7 <com/ekko/Main>
 2 dup
 3 astore_1
 4 monitorenter
 5 aload_1
 6 monitorexit
 7 goto 15 (+8)
10 astore_2
11 aload_1
12 monitorexit
13 aload_2
14 athrow
15 return

1. 准备锁对象

  • 0: ldc #7 <com/ekko/Main>

    • 动作:从常量池加载 com.ekko.Main 的类对象(Class Object)
    • [ ClassRef ]
    • 说明:我们要对这个类对象加锁
  • 2: dup

    • 动作:复印栈顶的 Class 引用
    • [ ClassRef, ClassRef ]
    • 一份引用要马上被 monitorenter 吃掉用来加锁。
    • 另一份引用必须保存起来(存入局部变量),以备将来 解锁(monitorexit) 时使用

2. 加锁并执行

每个对象都有一个monitor监视器与之对应,而这里正是去获取对象监视器的所有权,一旦monitor所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)

  • 3: astore_1

    • 动作:把栈顶的一份引用弹出,存入局部变量表 Slot 1
    • [ ClassRef ]
    • 说明:Slot 1 里的这个变量就是我们备份的“锁对象”
  • 4: monitorenter

    • 动作:弹出栈顶剩下的那个 Class 引用,尝试获取它的 Monitor 锁
    • [ ]
    • 说明:代码执行到这,线程就持有了锁
  • 5: aload_1

    • 动作:从 Slot 1 把刚才备份的 Class 引用拿出来,压入栈顶
    • [ ClassRef ]
    • 说明:因为同步块里是空的,没有任何业务逻辑,所以直接准备解锁了。为了解锁,必须先把锁对象拿回栈上
  • 6: monitorexit

    • 动作:弹出栈顶引用,释放锁。
    • [ ]
    • 说明:正常执行结束,释放锁。
  • 7: goto 15

    • 动作:跳到第 15 行(return),结束方法。

3. 捕获异常

在 10 - 14 行是编译器自动生成的异常处理逻辑

JVM 必须保证:即使同步块里抛出了异常,锁也必须被释放,否则会造成死锁

  • 10: astore_2

    • 触发场景:如果在第 4 行 (monitorenter) 到第 6 行 (monitorexit) 之间发生了异常,程序会跳到这里(通过异常表跳转)
    • 动作:把捕获到的异常对象保存到 Slot 2
  • 11: aload_1

    • 动作:赶紧把 Slot 1 里备份的锁对象拿出来。
    • 说明:这时候你就明白为什么最开始需要 dupastore_1 了吧?就是为了这种危急时刻能找到锁对象
  • 12: monitorexit

    • 动作:释放锁。
    • 说明:确保锁被释放
  • 13: aload_2

    • 动作:把刚才存起来的异常对象拿回栈顶
  • 14: athrow

    • 动作:把异常重新抛出去,让外层代码处理
alt text
alt text

对象头

对象是存放在堆内存中的,而每个对象内部,都有一部分空间用于存储对象头信息,而对象头信息中,则包含了Mark Word用于存放hashCode和对象的锁信息,在不同状态下,它存储的数据结构有一些不同

对象头的组成结构

在 64 位 JVM(开启指针压缩)中,普通对象的对象头通常占用 12 字节。它主要包含三个部分(如果是数组则是三个):

组成部分英文名称大小 (64位 JVM)作用描述
标记字段Mark Word8 字节 (64 bit)核心 存储对象的运行时数据:哈希码、GC分代年龄、锁状态标志等。
类型指针Klass Pointer4 字节 (开启压缩)指向方法区中该类的元数据(Class 对象),JVM 通过它知道“我是谁”。
数组长度Array Length4 字节仅数组对象才有 记录数组的长度
Mark Word

这是对象头里最复杂、最动态的部分

为了节省内存,Mark Word 的格式是不固定的,它会随着对象状态的变化而“复用”这 64 个 bit

alt text
alt text

它主要记录以下几类信息:

  • 锁状态:你刚才问的 synchronized,锁到底存在哪?就存在这里

  • GC 信息:对象活了多久(年龄)

  • 身份信息:对象的 HashCode

锁状态存储内容锁标志位 (最后2bit)
无锁 (Normal)Unused (25bit) + HashCode (31bit) + GC年龄 (4bit)01
偏向锁 (Biased)Thread ID (线程ID) + Epoch + GC年龄 (4bit)01 (偏向位 1)
轻量级锁 (Lightweight)指向栈中锁记录 (Lock Record) 的指针00
重量级锁 (Heavyweight)指向堆中 Monitor (互斥量) 的指针10
GC 标记11
内存示例

假设你写了一个简单的类:

class Hero {
    int hp; // 4 bytes
}
Hero h = new Hero();

这个对象 h 在内存里长这样(开启指针压缩):

|----------------------------------------------------------|
|                     Object Header (12 Bytes)             |
|----------------------------------------------------------|
|  Mark Word (8 Bytes)   |  Klass Pointer (4 Bytes)        |
|  (锁/Hash/Age)         |  (指向 Hero.class)               |
|----------------------------------------------------------|
|                     Instance Data (4 Bytes)              |
|----------------------------------------------------------|
|  int hp (4 Bytes)                                        |
|----------------------------------------------------------|
|                     Padding (对齐填充)                    |
|----------------------------------------------------------|
|  JVM 要求对象大小必须是 8 的倍数。                        |
|  12 + 4 = 16,正好是 8 的倍数,所以这里不需要 Padding。      |
|----------------------------------------------------------|
上次编辑于: