【Java多线程】了解线程的锁池和等待池概念

一.内置锁

Java提供了一种内置的锁机制来支持原子性可见性同步代码块(Synchronized Block)

synchronized的原理有两个:

  • 内置锁
  • 互斥锁

Java的内置锁:每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。线程进入同步代码块或方法的时候会自动获得该锁,并且在退出同步代码块时(正常返回,或者是异常退出)会自动释放锁。获得内置锁的唯一途径就是进入这个锁的保护的同步代码块或方法。

  • 而Java的内置锁又是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,当线程B尝试去获得线程A持有的内置锁时,线程B必须 等待(WAITING)或者阻塞(BLOCKED),直到线程A释放这个锁,如果A线程不释放这个锁,那么B线程将永远等待下去。

同步代码块以关键字synchronized修饰,例如:

synchronized(锁对象引用){
    
    
//锁保护的代码块
}

如果synchronized修饰的是对象的方法,被修饰的方法体就是同步代码块,锁的对象引用就是被修饰的方法所在的对象。

public class SyncTest {
    
    
    public synchronized void method() {
    
    
    //方法体就是同步代码块
    } 
}

如果synchronized修饰的是静态方法,那被修饰的方法体就是同步代码块,锁的对象引用就是被修饰的方法所在的Class对象。

public class SyncTest {
    
    
    public static synchronized void method() {
    
    
    //方法体就是同步代码块
    } 
}

如果synchronized修饰的是某一代码块,需要指定synchronized的锁对象引用

内置锁的特性

  • 互斥:同一时间最多只有一个线程能够持有这种锁。
    线程尝试获取一个被其它线程占用的内置锁,线程必须等待(自旋)或者阻塞(自旋策略失效),并且 因为请求内置锁而被阻塞的线程不能被中断
  • 可重入:也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。作用是防止在同一线程中多次获取锁而导致死锁发生。

实现原理:为每个锁关联一个请求计数器和一个占有它的线程。当计数值为0,表示这个锁没有被任何线程持有。线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数器置为1 。如果同一个线程再次请求这个锁,计数器将递增;

  • 每次占用线程退出同步块,计数器值将递减。直到计数器为0,锁被释放。

二.线程状态

线程的5种状态

线程有多种状态的切换,在早期的jdk版本中,线程之间的切换主要是通过join,sleep,wait,notify,notifyAll等方法来进行状态转换的。

线程共包括以下5种状态。
大致分为创建(New)、可运行(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)等五个状态。

  1. 可运行(Runnable)状态和运行(Running)状态可以相互转换阻塞状态(Blocked)和可运行(Runnable)状态可以相互转换。
  2. 线程只能从就绪状态进入到运行状态。
  1. 新建(NEW):新创建了一个线程对象。

  2. 可运行(RUNNABLE):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中获取cpu 的使用权

  3. 运行(RUNNING)可运行状态(runnable)的线程获得了cpu 时间片(timeslice) ,执行程序代码。

  4. 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu时间片暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu时间片 转到运行(running)状态。阻塞的情况分三种:

    • .等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
    • 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
    • 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态
  5. 死亡(DEAD)线程run()、main() 方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

线程状态图

线程之间的切换状态如下图所示:
在这里插入图片描述

二.初始状态

  • 实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态

三.可运行状态

  • 调用的start()方法,线程进入可运行状态。
  • 可运行状态只是说明有资格运行,线程调度程序没有挑选到你,你就永远是可运行状态
  • 当前线程sleep()结束,其他线程join()结束,阻塞式IO方法返回,某个线程拿到对象锁,这些线程也将进入可运行状态
  • 当前线程时间片用完了 或者 调用当前线程的yield(),当前线程进入可运行状态
  • 锁池里的线程拿到对象锁后,进入可运行状态。
  • 线程调用wait()进入WAITING状态,其他线程调用notify()/nofifyAll()唤醒相关线程
  • 处于挂起状态的线程调用了resume()恢复线程
  • 线程调用的阻塞IO已经返回 或者 阻塞方法执行完毕

四.运行状态

  • 线程调度程序从可运行池(锁池)中选择一个线程作为当前线程时所处的状态, 真正开始执行run()方法,这也是线程进入运行状态的唯一一种方式

五.死亡状态

  • 当线程的run()方法完成时,或者主线程的main()方法完成时, 异常退出run方法,我们就认为它死去。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦死亡,就不能复生。
  • 在一个死去的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

为了确定线程在当前是否存活着(就是要么是可运行的,要么是被阻塞了),需要使用isAlive()。如果是可运行或被阻塞,返回true; 如果线程仍旧是new状态不是可运行的, 或者线程死亡了,则返回false.

六.阻塞状态

  • 当前线程T调用Thread.sleep()方法,主动放弃占用的cpu资源,当前线程进入阻塞状态。
  • 运行在当前线程里的其它线程t2调用join()方法,当前线程进入阻塞状态。
  • 线程调用了阻塞式IO方法,在方法返回前,该线程被阻塞
  • 线程尝试获得一个锁,但是该锁正被其他线程所持有
  • 程序调用了suspend()方法,挂起该线程(此方法容易导致死锁,应该避免调用)
  • 线程等待某个通知

线程释放锁的情况

  1. 执行完同步代码块会释放对象锁;
  2. 执行同步代码块的过程中,如果遇到异常导致线程终止,锁也会被释放;
  3. 执行同步代码块的过程中,执行了锁所属对象的wait()方法,此线程会释放对象锁,进入等待队列中,等待被唤醒。

线程阻塞和线程等待的区别

  1. 线程阻塞(BLOCKED) : 一个处于就绪状态(Running)的线程尝试去获取锁,但锁已经被其他线程占用, 导致当前线程被阻塞的状态(synchronize 关键字产生的状态)

    进入BLOCKED状态的只有synchronize关键字,ReentrentLock.lock()底层调用的是LockSupport.park(),因此ReentrentLock.lock()进入的是WAITING状态

  2. 线程等待(WAITING) : 一个线程已经获取到了锁,但是需要等待其他线程执行某些操作。时间不确定
    当钱线程调用wait,join,park方法时,进入WAITING状态。(前提是这个线程已经拥有锁)

  3. 超时等待(TIMED_WAITING) : 一个线程已经获取到了锁,但是需要等待其他线程执行某些操作。时间确定
    通过sleep(int timeout)Wait(int timeout)方法进入的限时等待的状态)

实际上可以不用区分两者, 因为两者都会暂停线程的执行.
两者的区别是:

  1. 进入WAITING状态是线程主动的, 而进入BLOCKED状态是被动的.
  2. 更进一步的说, 进入BLOCKED状态是在同步代码块之外的, 而进入WAITING状态是在同步代码块之内.

sleep、join、yield、wait区别

  • sleep 不释放当前对象监视器的锁、释放cpu
  • join 释放对象监视器的锁、抢占cpu
  • yield 不释放对象监视器的锁、释放cpu
  • wait 释放对象监视器的锁、释放cpu

记住一句话:cpu是非常宝贵的,所以只有running的时候才会获取CPU时间片。

join底层还是wait()实现的,会释放锁,进入waiting状态(简单说,在哪个线程的线程体中调用join,哪个线程就会进入等待状态,并且释放锁)

yield不释放锁案例

线程让步 yield(): 不释放锁,释放CPU,让当前线程从“运行状态”进入到“就绪状态”,从而让其它具有相同优先级的等待线程获取执行权;但并不能保证在当前线程调用yield()之后,其它具有相同优先级的线程就一定能获得执行权;·也有可能是当前线程又进入到“运行状态”继续运行!·

public class YieldLockTest {
    
    
    private static Object obj = new Object();

    public static void main(String[] args) {
    
    
        ThreadA t1 = new ThreadA("t1");
        ThreadA t2 = new ThreadA("t2");
        ThreadA t3 = new ThreadA("t3");
        t1.start();
        t2.start();
        t3.start();
    }

    static class ThreadA extends Thread {
    
    
        public ThreadA(String name) {
    
    
            super(name);
        }

        @Override
        public void run() {
    
    
            // 获取obj对象的同步锁
            synchronized (obj) {
    
    
                for (int i = 0; i < 10; i++) {
    
    
                    System.out.printf("%s [%d]:%d\n", this.getName(), this.getPriority(), i);
                    // i整除4时,调用yield,
                    if (i % 4 == 0) {
    
    
                        Thread.yield();
                    }

                }
            }
        }
    }
}

在这里插入图片描述
主线程main中启动了两个线程t1和t2,t3。t1和t2,t3,在run()会引用同一个对象的同步锁,即synchronized(obj)。在t1运行过程中,虽然它会调用Thread.yield(),释放cpu执行权;但是,t2,t3是不会获取cpu执行权的。因为,t1并没有释放“obj所持有的同步锁”!

sleep不释放锁案例

线程休眠sleep(): 不释放锁,释放CPU,让当前线程休眠,即当前线程会从“运行状态”进入到“休眠(阻塞)状态”。线程休眠时间结束时,它会由“阻塞状态”变成“就绪状态”,从而等待cpu的调度执行。

//还是上面的代码,i % 4 == 0 调用 Thread.sleep(100) 休眠 100毫秒
            // 获取obj对象的同步锁
            synchronized (obj) {
    
    
                try {
    
    
                    for (int i = 0; i < 10; i++) {
    
    
                        System.out.printf("%s: %d\n", this.getName(), i);
                        // i能被4整除时,休眠100毫秒
                        if (i % 4 == 0) {
    
    
                            Thread.sleep(100);
                        }
                    }
                } catch (InterruptedException e) {
    
    
                    e.printStackTrace();
                }
            }

在这里插入图片描述
结果说明:
主线程main中启动了两个线程t1和t2,t3。t1和t2,t3在run()会引用同一个对象的同步锁,即synchronized(obj)。在t1运行过程中,虽然它会调用Thread.sleep(100)释放cpu执行权;但是,t2,t3是不会获取cpu执行权的。因为,t1并没有释放“obj所持有的同步锁”!

三.监视器(monitor)以及锁池和等待池概念

初识监视器(monitor)

可以将监视器比作一个建筑,它有一个很特别的房间,房间里有一些数据,而且在同一时间只能被一个线程占据。 一个线程从进入这个房间到它离开前,它可以独占地访问房间中的全部数据。

如果用一些术语来定义这一系列动作:

  • 进入这个建筑叫做“进入监视器”
  • 进入建筑中的那个特别的房间叫作“获得监视器”
  • 占据房间叫做“持有监视器”
  • 离开房间叫做“释放监视器”
  • 离开建筑叫做“退出监视器”
    在这里插入图片描述

初识锁

  1. 虽然叫做锁,但是其实相当于临界区大门的一个钥匙,那把钥匙就放到了临界区门口,有人进去了就把钥匙拿走揣在了身上,结束之后会把钥匙还回来只有拿到了指定临界区的锁,才能够进入临界区,访问临界区资源,当离开临界区时释放锁,其他线程才能够进入临界区

  2. 而对于锁本身,也是一种临界资源,是不允许多个线程共同持有的,同一时刻,只能够一个线程持有;

  1. Java中任何一个对象都可以被当做锁
  2. 在Java对象头中有一部分数据用于记录线程与对象的锁之间的关系,通过这个对象锁,进而可以控制线程对于对象的互斥访问,在JVM中每个对象中都拥有这样的数据
  3. 如果任何线程想要访问该对象的实例变量,那么线程必须拥有该对象的锁(也就是在指定的内存区域中进行一些数据的写入)

一个线程拥有了一个对象的锁之后,他就可以再次获取锁,也就是经常说的可重入

如下图所示,两个方法共用同一个锁, 在methodA中调用了methodB,如果不可重入的话: 一个线程获取了锁,进入methodA然后等待进入methodB的锁,但是他们是同一个锁,自己等待自己,岂不是死锁了所以锁具有可重入的特性

public class ReentrantTest {
    
    
    public static void main(String[] args) {
    
    
        ReentrantTest reentrantTest = new ReentrantTest();

        new Thread(()-> {
    
    
            reentrantTest.methodA();
        }).start();

        new Thread(()-> {
    
    
            reentrantTest.methodA();
        }).start();

        new Thread(()-> {
    
    
            reentrantTest.methodA();
        }).start();
    }

    private static final Object obj = new Object();


    public  void methodA() {
    
    
        synchronized (obj) {
    
    
            System.out.println(Thread.currentThread().getName()+"=>methodA start="+  System.currentTimeMillis());

            try {
    
    
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
            methodB();//进入methodB

            System.out.println(Thread.currentThread().getName()+"=>methodA end="+  System.currentTimeMillis());
        }
    }

    public void methodB() {
    
    
        synchronized (obj) {
    
    
            System.out.println(Thread.currentThread().getName()+"=>methodB start="+  System.currentTimeMillis());

            try {
    
    
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }

            System.out.println(Thread.currentThread().getName()+"=>methodB end="+  System.currentTimeMillis());
        }
    }
}

在这里插入图片描述

  1. 对于锁的可重入性,JVM会维护一个计数器,记录对象被加锁了多少次没有被锁的对象是0,后续每重入一次计数器加1 只有自己可以重入,别人是不可以,是互斥的)只有计数器为0时,其他的线程才能够进入,所以,同一个线程加锁了多少次,也必然对应着释放多少次

  2. JVM会帮助我们解决计数器的维护,锁的获取与释放等,因此开发人员不需要直接接触锁

监视器(monitor)概念描述

java虚拟机给每个对象的class字节码都设置了一个监听器Monitor。
我们可以把Monitor想象成一个保险箱,里面专门存放一些需要被保护的数据。
Monitor每次只允许一个线程进入,当一个线程需要访问受保护的数据(即需要获取对象的Monitor)时,它会首先在entry-set入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的Monitor,那么它会和entry-set队列和wait-set队列中的被唤醒的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的Monitor,执行受保护的代码段,执行完毕后释放Monitor,如果已经有线程持有对象的Monitor,那么需要等待其释放Monitor后再进行竞争。

再说一下wait-set队列。当一个线程拥有Monitor后,经过某些条件的判断(比如用户取钱发现账户没钱),这个时候需要调用Object的wait方法,线程就释放了Monitor,进入wait-set队列,等待Object的notify方法(比如用户向账户里面存钱)。当该对象调用了notify方法或者notifyAll方法后,wait-set中的线程就会被唤醒,然后在wait-set中被唤醒的线程和entry-set中的线程一起通过CPU调度来竞争对象的Monitor,最终只有一个线程能获取对象的Monitor

在这里插入图片描述
The Owner: 同步代码
Entry Set:(锁池): 保存等待获取对象锁的所有线程
Wait Set:(等待池): 保存执行了objectX.wait()/wait(long)的状态为WAITTING的所有线程
enter:进入锁池
acquire: 获取到锁
release: 释放锁进入Wait Set(等待池)
release and exit: 释放锁和退出同步代码块

监视器(monitor)概念加强

Java中每个对象都有一个唯一与之对应的内部锁(Monitor)JVM会为每个Monitor维护两个“队列-> Entry Set 和 Wait Set”(姑且称之为“队列”,尽管它不一定符合数据结构上队列的“先进先出”原则)

Entry Set和Wait Set,也有人翻译为锁池和等待池,意思基本一致。其实个人的理解可以认为是就绪队列和等待队列
锁池是在同步的环境下才有的概念,一个对象对应一个锁池。

  • 一个叫Entry Set(入口集),另外一个叫Wait Set(等待集)。对于任意的对象objectX, objectX的Entry Set用于保存等待获取objectX对应的内部锁的所有线程。objectX的Wait Set用于保存执行了objectX.wait()/wait(long)的线程。

    对于Entry Set: 如果线程A已经持有了对象锁,此时如果有其他线程也想获得该对象锁的话,它只能进入Entry Set,并且处于线程的BLOCKED状态。
    对于Wait Set: 如果线程A调用了wait()方法,那么线程A会释放该对象的锁,进入到Wait Set,并且处于线程的WAITING状态。

    线程B想要获得对象锁,一般情况下有两个先决条件,1是对象锁已经被释放了(如曾经持有锁的前任线程A执行完了synchronized代码块或者调用了wait()方法等等),2是当前线程已处于RUNNABLE状态
    .
    对于Wait Set中的线程,当对象的notify()/notifyAll()方法被调用时,JVM会唤醒处于Wait Set中属于某一个或者全部线程,这些线程的状态就从WAITING转变为RUNNABLE,在wait-set中被唤醒的线程和entry-set中的线程一起通过CPU调度来竞争对象的锁,最终只有一个线程能获取对象的锁。
    .
    每当对象的锁被释放后,所有处于RUNNABLE状态的线程会共同去竞争获取对象的锁,最终会有一个线程(具体哪一个取决于JVM实现)真正获取到对象的锁,而其他竞争失败的线程继续在Entry Set中等待下一次机会。

假设objectX是任意一个对象,monitorX是这个对象对应的内部锁,现有线程A、B、C同时申请monitorX

  • 由于任意一个时刻只有一个线程能够获得 (占用/持有) 这个锁,因此除了胜出 (即获得了锁) 的线程 (这里假设是B) 外,其他线程 (这里就是A和C) 都会被暂停 (线程的生命周期状态会被调整为BLOCKED)

这些 因申请锁而落选的线程 就会被存入objectX对应的 Entry Set(以下记为entrySetX)之中。当monitorX被其持有线程 (这里就是B) 释放时,entrySetX中的一个任意 (注意是“任意”,而不一定是Entry Set中等待时间最长或者最短的) 线程会被唤醒 (即线程的生命周期状态变更为RUNNABLE) 。这个被唤醒的线程会与其他活跃线程 (即不处于Entry Set之中,且线程的生命周期状态为RUNNABLE的线程) 再次抢占monitorX。这时,被唤醒的线程如果成功申请到monitorX,那么该线程就从entrySetX中移除。否则,被唤醒的线程仍然会停留在entrySetX,并再次被暂停,以等待下次申请锁的机会。

如果有个线程执行了 objectX.wait(),那么该线程就会被暂停 (线程的生命周期状态会被调整为WAITTING) 并被存入objectX的 Wait Set (以下记为waitSetX)之中。此时,该线程就被称为 objectX的等待线程 。当其他线程执行了 objectX.notify()/notifyAll() 时 ,waitSetX 中的一个 (或者多个,取决于被调用的是notify还是notifyAll方法) 任意 (注意是“任意”,而不一定是Wait Set中等待时间最长或者最短的) 等待线程会被唤醒 (线程的生命周期状态变更为RUNNABLE)这些被唤醒的线程会与entrySetX中被唤醒的线程以及其他(可能的)活跃线程共同参与抢夺monitorX。如果其中一个被唤醒的等待线程成功申请到锁,那么该线程就会从waitSetX中移除。否则,这些被唤醒的线程仍然停留在waitSetX中,并再次被暂停,以等待下次申请锁的机会。

个人理解

调用对象的 notifyAll方法后,Wait Set 上的线程不会会加入到 EntrySet 中

从Java虚拟机性能的角度来说,Java虚拟机没有必要在notifyAll调用之后“将Wait Set中的线程移入Entry Set”

  • 首先,从一个“队列”移动到另外一个“队列”是有开销的,其次,虽然notifyAll调用后Wait Set中的多个线程会被唤醒,但是这些被唤醒的线程极端情况下可能没有任何一个能够获得锁(比如被其他活跃线程抢先下手了)或者即便可以获得锁也可能不能继续运行(比如这些等待线程所需的等待条件又再次不成立)。那么这个时候,这些等待线程仍然需要老老实实在wait set中待着。因此,如果notifyAll调用之后就将等待线程移出Wait set会导致浪费(白白地进出“队列”)。

这点可以参考显式锁的实现:
java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(Node, int)

 /**
     * Acquires in exclusive uninterruptible mode for thread already in
     * queue. Used by condition wait methods as well as acquire.
     *
     * @param node the node
     * @param arg the acquire argument
     * @return {@code true} if interrupted while waiting
     */
    final boolean acquireQueued(final Node node, int arg) {
    
    
        boolean failed = true;
        try {
    
    
            boolean interrupted = false;
            for (;;) {
    
    
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
    
    
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
    
    
            if (failed)
                cancelAcquire(node);
        }
    }

使用显式锁时,被唤醒的线程获取到锁tryAcquire调用返回true)之后才被从wait set中移出(setHead调用)。

Lock接口和synchronzied关键字对比主要有以下:

  • Lock需要显示地获取和释放锁,繁琐能让代码更灵活
  • Synchronized不需要显示地获取和释放锁,简单

Lock接口主要功能有以下:

  • 使用Lock可以方便的实现公平性
  • 非阻塞的获取锁
  • 能被中断的获取锁
  • 超时获取锁

java锁与监视器概念
多线程与Java-集合

猜你喜欢

转载自blog.csdn.net/qq877728715/article/details/108880980