3.4、原理之线程运行

栈与栈帧

java Virtual Machin Stack

JVM是由堆、栈、方法区所组成,其中占内存就是给线程使用的,每个线程使用后,虚拟机就会为其分配一块内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。
  • 每个线程只占用一个活动栈帧,对应着当前正在执行的那个方法
  • 多个线程互相独立

代码

  1. package juc;
  2. public class T1 {
  3. public static void main(String[] args) {
  4. method1(1);
  5. }
  6. private static void method1(int x){
  7. int y = x + 1;
  8. Object m= method2();
  9. System.out.println(m);
  10. }
  11. private static Object method2(){
  12. return new Object();
  13. }
  14. }

8ce0af56c98cd1ec228ef4ce5f94f93.jpg

线程上下文切换(Thread Context Switch)

场景:

  • 时间片用完
  • 垃圾回收
  • 有高优先级抢占
  • 线程自发的用sleep,yield,lock 等方法

当场景发生时,需要保存当前的线程状态,Java对应的是程序计数器,他的作用是记录下一条jvm指令的执行地址,是线程私有的。

  • 状态包括程序计数器,虚拟机栈中的每个栈中的信息,如局部变量,返回地址等
  • 上下文切换会影响性能

线程中的常见方法

(1) setName:设置线程名称,使之与参数name相同;
(2) getName:返回该线程的名称;
(3) start:使该线程开始执行,Java虚拟机底层调用该线程的start0( )方法;
(4) run:调用线程对象run方法。start底层会创建新的线程,run是一个简单的方法调用,不会启动新线程。
(5) setPriority:更改线程的优先级;
(6) getPriority:获取线程的优先级;
(7) sleep:在指定的毫秒数内让当前正在执行的线程休眠;
(8) interrupt:中断线程,但并没有真正结束线程,所以一般用于中断正在休眠线程。
(9) yield:线程的礼让。
(10) join:线程的插队

start && run

run:称为线程体,包含了要执行的这个线程的内容,方法运行结束,此线程随即终止。直接调用 run 是在主线程中执行了 run,没有启动新的线程,需要顺序执行
start:使用 start 是启动新的线程,此线程处于就绪(可运行)状态,通过新的线程间接执行 run 中的代码
说明:线程控制资源类
run() 方法中的异常不能抛出,只能 try/catch

  • 因为父类中没有抛出任何异常,子类不能比父类抛出更多的异常
  • 异常不能跨线程传播回 main() 中,因此必须在本地进行处理

sleep与yield

1、调用sleep会让当前线程从Running进入TimeWaiting状态
2、其他线程可以使用interrupt方法打断正在睡眠的线程,这时sleep方法会抛出InterruptedException
3、睡眠结束后的线程未必会立刻得到执行
4、建议用TimeUnit的sleep代替Thread的sleep来获得更好的可读性
image.png

yield

1、调用yield会让当前线程从Running到Runnable状态,然后调度执行其他同优先级的线程,如果这时没有同优先级的线程,那么不能保证让当前线程暂停的效果。
2、具体的实现依赖于系统的任务调度器

线程优先级

:::tips new Thread(“t1”).setPriority(1); :::

  • 更多的只是提示作用,调度器有时会忽略
  • 如果cpu比较忙,那么优先级高的线程会获得更多的时间片,但cpu闲时,优先级几乎没有任何作用。

join方法

等待线程运行结束(可以看作强制同步)

4.4、变量的线程安全分析

成员变量和静态变量是否线程安全

  • 只读安全
  • 存在读写则不安全

局部变量是否线程安全

  • 局部变量时线程安全的
  • 但局部变量引用的对象未必
    • 如果该对象没有逃离方法的作用访问,则是线程安全的
    • 如果该对象逃离方法的作用范围,则需要考虑线程安全

image.png

Tips:

  • 如果不想被子类继承,或者暴露方法则用 private修饰
  • 如果不想被子类重写则用 fianl修饰(通常在抽象类中的抽象方法)
  • 抽象父类的抽象方法的具体实现不确定,因此称之为“外星方法”
  • 具体参考String

image.png

开闭原则:对于扩展是开放的,但是对于修改是封闭的”

常见的线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent包下的类

多个线程调用同一个实例的某个方法时,是线程安全的

  • 方法时原子的
  • 方法的组合不是原子的

image.png

不可变类线程安全

  • String
  • Integer

解决多线程问题

  • 找出临界区(有读写操作)
  • 进行加锁

Monitor

Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,Monitor 也是 class,其实例存储在堆中,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针,这就是重量级锁

  • Mark Word 结构:最后两位是锁标志位黑马 - 图6

    ptr_to_heavyweight_monitor:30 (地址30位) | 10 (2位标记位) 重量级锁

  • 64 位虚拟机 Mark Word:黑马 - 图7

工作流程:

  • 开始时 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中(轻量级锁部分详解)

黑马 - 图8

  • 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList BLOCKED(双向链表)(等待)
  • Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
  • 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
  • WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)

黑马 - 图9
注意:

  • synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

字节码

代码:

  1. public static void main(String[] args) {
  2. Object lock = new Object();
  3. synchronized (lock) {
  4. System.out.println("ok");
  5. }
  6. }
  1. 0: new #2 // new Object
  2. 3: dup
  3. 4: invokespecial #1 // invokespecial <init>:()V,非虚方法
  4. 7: astore_1 // lock引用 -> lock
  5. 8: aload_1 // lock (synchronized开始)
  6. 9: dup // 一份用来初始化,一份用来引用
  7. 10: astore_2 // lock引用 -> slot 2
  8. 11: monitorenter // 【将 lock对象 MarkWord 置为 Monitor 指针】
  9. 12: getstatic #3 // System.out
  10. 15: ldc #4 // "ok"
  11. 17: invokevirtual #5 // invokevirtual println:(Ljava/lang/String;)V
  12. 20: aload_2 // slot 2(lock引用)
  13. 21: monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
  14. 22: goto 30
  15. 25: astore_3 // any -> slot 3
  16. 26: aload_2 // slot 2(lock引用)
  17. 27: monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
  18. 28: aload_3
  19. 29: athrow
  20. 30: return
  21. Exception table:
  22. from to target type
  23. 12 22 25 any
  24. 25 28 25 any
  25. LineNumberTable: ...
  26. LocalVariableTable:
  27. Start Length Slot Name Signature
  28. 0 31 0 args [Ljava/lang/String;
  29. 8 23 1 lock Ljava/lang/Object;

说明:

  • 通过异常 try-catch 机制,确保一定会被解锁
  • 方法级别的 synchronized 不会在字节码指令中有所体现

锁升级

升级过程

synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化 :::tips 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争的增加,只能锁升级,不能降级 ::: 黑马 - 图10

偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要同步操作:

  • 当锁对象第一次被线程获得的时候进入偏向状态,标记为 101,同时使用 CAS 操作将线程 ID 记录到 Mark Word。如果 CAS 操作成功,这个线程以后进入这个锁相关的同步块,查看这个线程 ID 是自己的就表示没有竞争,就不需要再进行任何同步操作
  • 当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定或轻量级锁状态

黑马 - 图11
一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,MarkWord 值为 0x05 即最后 3 位为 101,thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟。JDK 8 延迟 4s 开启偏向锁原因:在刚开始执行代码时,会有好多线程来抢锁,如果开偏向锁效率反而降低
  • 当一个对象已经计算过 hashCode,就再也无法进入偏向状态了
  • 添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

撤销偏向锁的状态:

  • 调用对象的 hashCode:偏向锁的对象 MarkWord 中存储的是线程 id,调用 hashCode 导致偏向锁被撤销
  • 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
  • 调用 wait/notify,需要申请 Monitor,进入 WaitSet

批量撤销:如果对象被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

  • 批量重偏向:当撤销偏向锁阈值超过 20 次后,JVM 会觉得是不是偏向错了,于是在给这些对象加锁时重新偏向至加锁线程
  • 批量撤销:当撤销偏向锁阈值超过 40 次后,JVM 会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的


轻量级锁

一个对象有多个线程要加锁,但加锁的时间是错开的(没有竞争),可以使用轻量级锁来优化,轻量级锁对使用者是透明的(不可见)
可重入锁:线程可以进入任何一个它已经拥有的锁所同步着的代码块,可重入锁最大的作用是避免死锁
轻量级锁在没有竞争时(锁重入时),每次重入仍然需要执行 CAS 操作,Java 6 才引入的偏向锁来优化
锁重入实例:

  1. static final Object obj = new Object();
  2. public static void method1() {
  3. synchronized( obj ) {
  4. // 同步块 A
  5. method2();
  6. }
  7. }
  8. public static void method2() {
  9. synchronized( obj ) {
  10. // 同步块 B
  11. }
  12. }

黑马 - 图12

  • 让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
  • 如果 CAS 替换成功,对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁 黑马 - 图13
  • 如果 CAS 失败,有两种情况:黑马 - 图14
    • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是线程自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数
  • 当退出 synchronized 代码块(解锁时)

    • 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
    • 如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
      • 成功,则解锁成功
      • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
        锁膨胀
        原因:在尝试加轻量级锁的过程中,CAS 操作无法成功,可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁,然后将线程加入阻塞队列中
  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁黑马 - 图15

  • Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED黑马 - 图16
  • 当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

锁优化

自旋锁

重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁
注意:

  • 自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势
  • 自旋失败的线程会进入阻塞状态

优点:不会进入阻塞状态,减少线程上下文切换的消耗
缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源
自旋锁情况:

  • 自旋成功的情况: 黑马 - 图17
  • 自旋失败的情况:黑马 - 图18

自旋锁说明:

  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
  • Java 7 之后不能控制是否开启自旋功能,由 JVM 控制

    1. //手写自旋锁
    2. public class SpinLock {
    3. // 泛型装的是Thread,原子引用线程
    4. AtomicReference<Thread> atomicReference = new AtomicReference<>();
    5. public void lock() {
    6. Thread thread = Thread.currentThread();
    7. System.out.println(thread.getName() + " come in");
    8. //开始自旋,期望值为null,更新值是当前线程
    9. while (!atomicReference.compareAndSet(null, thread)) {
    10. Thread.sleep(1000);
    11. System.out.println(thread.getName() + " 正在自旋");
    12. }
    13. System.out.println(thread.getName() + " 自旋成功");
    14. }
    15. public void unlock() {
    16. Thread thread = Thread.currentThread();
    17. //线程使用完锁把引用变为null
    18. atomicReference.compareAndSet(thread, null);
    19. System.out.println(thread.getName() + " invoke unlock");
    20. }
    21. public static void main(String[] args) throws InterruptedException {
    22. SpinLock lock = new SpinLock();
    23. new Thread(() -> {
    24. //占有锁
    25. lock.lock();
    26. Thread.sleep(10000);
    27. //释放锁
    28. lock.unlock();
    29. },"t1").start();
    30. // 让main线程暂停1秒,使得t1线程,先执行
    31. Thread.sleep(1000);
    32. new Thread(() -> {
    33. lock.lock();
    34. lock.unlock();
    35. },"t2").start();
    36. }
    37. }