多线程

157 阅读30分钟

一、创建线程有几种方式:

创建线程有三种方式,分别是继承Thread类、实现Runnable接口、实现Callable接口。

通过继承Thread类来创建并启动线程的步骤:

  • 1.定义Thread类的子类,并重写该类的run()方法,该run()方法将作为线程执行体。
  • 2.创建Thread子类的实例,即创建了线程对象。
  • 3.调用线程对象的start()方法来启动该线程。

通过实现Runnable接口来创建并启动线程的步骤:

    1. 定义Runnable接口的实现类,并实现该接口的run()方法,该run()方法将作为线程执行体。
    1. 创建Runnable实现类的实例,并将其作为Thread的target来创建Thread对象,Thread对象为线程对象。
    1. 调用线程对象的start()方法来启动该线程。

通过实现Callable接口来创建并启动线程的步骤如下:

    1. 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有 返回值。然后再创建Callable实现类的实例。
    1. 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返 回值。
    1. 使用FutureTask对象作为Thread对象的target创建并启动新线程。
    1. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

二、说说Thread类的常用方法

Thread类常用构造方法:

  • Thread()
  • Thread(String name)
  • Thread(Runnable target)
  • Thread(Runnable target,String name)

其中,参数name为线程名,参数target为包含线程体的目标对象。

Thread类常用静态方法:

  • currentThread():返回当前正在执行的线程
  • interrupted():返回当前执行的线程是否已经被中断
  • sleep(long millis):使当前执行的线程睡眠多少毫秒数
  • yield():使当前执行的线程自愿暂时放弃对处理器的使用权并允许其他线程执行

Thread类常用实例方法:

  • getId():返回该线程的id;
  • getName():返回该线程的名字;
  • getPriority():返回该线程的优先级;
  • interrupt():使该线程中断;
  • isInterrupted():返回该线程是否被中断;
  • isAlive():返回该线程是否处于活动状态;
  • isDaemon():返回该线程是否是守护线程;
  • setDaemon(boolean on):将该线程标记为守护线程或用户线程,如果不标记默认是非守护线程; - setName(String name):设置该线程的名字;
  • setPriority(int newPriority):改变该线程的优先级;
  • join():等待该线程终止;
  • join(long millis):等待该线程终止,至多等待多少毫秒数。

三、run()和start()有什么区别

run()方法被称为线程执行体,它的方法体代表了线程需要完成的任务,而start()方法用来启动线程。调用start()方法启动线程时,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法会立即被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,如果直接调用线程对象的run()方法,系统会把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。

四、线程是否可以重复启动,会有什么后果

只能对处于新建状态的线程调用start()方法,否则将会引发illegalThreadStateException(非法线程状态异常)异常。

五、线程的生命周期

在现成的生命周期中,他要经过新建(new)、就绪(Ready)、运行(Running)、阻塞(Blocked)、死亡(Dead)五种状态。尤其是当线程启动以后,它不可能一直"霸占着"CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、就绪之间切换。 当线程使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和java对象一样,仅仅由java虚拟机为其分配内存,并初始化其成员变量的值。此时线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。 当线程调用start()方法之后,该线程处于就绪状态,java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时运行,取决于JVM里线程调度器的调度。 如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU那么在任何时刻只有一个线程处于运行状态。当然在一个多处理器的机器上,将会有多个线程并行执行;当线程数大于处理器数时,依然会出现多个线程在同一个CPU上轮换的现象。 当一个线程开始运行后,它不可能一直处于运行状态,线程在运行过程中需要被中断,目的是使其他线 程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言, 系统会给每个可执行的线程一个小时间段来处理任务。当该时间段用完后,系统就会剥夺该线程所占用 的资源,让其他线程获得执行的机会。当发生如下情况时,线程将会进入阻塞状态:

  • 线程调用sleep()方法主动放弃所占用的处理器资源。
  • 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
  • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。
  • 线程在等待某个通知(notify)。
  • 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。 针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态:
  • 调用sleep()方法的线程经过了指定时间。
  • 线程调用的阻塞式IO方法已经返回。
  • 线程成功地获得了试图取得的同步监视器。
  • 线程正在等待某个通知时,其他线程发出了一个通知。
  • 处于挂起状态的线程被调用了resume()恢复方法。

线程会以三种方式结束,结束后就处于死亡状态:

  • run()或者call()方法执行完成,线程正常结束。
  • 线程抛出一个未捕获的Exception或Error。
  • 直接调用该线程的stop()方法来结束该线程,该方法容易导致死锁,通常不推荐使用。

线程的五种状态转换关系:

image.png

六、如何实现线程同步

  1. 同步方法

    即有synchronized关键字修饰的方法,由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意,synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。

  2. 同步代码块

    即有synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实 现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必 要同步整个方法,使用synchronized代码块同步关键代码即可。

  3. ReentrantLock

    Java 5新增了一个java.util.concurrent包来支持同步,其中ReentrantLock类是可重入、互斥、实 现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其 能力。需要注意的是,ReentrantLock还有一个可以创建公平锁的构造方法,但由于能大幅度降低 程序运行效率,因此不推荐使用。

  4. volatile

    volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可 能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的 是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。

  5. 原子变量

    在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线 程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式 增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和 实用工具进行统一访问。

七、说一说java多线程之间的通信方式

在Java中线程通信主要有以下三种方式:

  1. wait()、notify()、notifyAll()

    如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现 线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每 个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当 前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本 地方法,并且被final修饰,无法被重写。 wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对 象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。 notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放 锁后竞争锁,进而得到CPU的执行。 每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争 锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而 等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。

  2. await()、signal()、signalAll()

    如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通 信。这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的 wait+notify实现线程间的协作,它的使用依赖于 Lock。相比使用wait+notify,使用Condition的 await+signal这种方式能够更加安全和高效地实现线程间协作。 Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 。 必须要注意 的是,Condition 的 await()/signal()/signalAll() 使用都必须在lock保护之内,也就是说,必须在 lock.lock()和lock.unlock之间才可以使用。事实上,await()/signal()/signalAll() 与 wait()/notify()/notifyAll()有着天然的对应关系。即:Conditon中的await()对应Object的wait(), Condition中的signal()对应Object的notify(),Condition中的signalAll()对应Object的notifyAll()。

  3. BlockingQueue

    Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用 途并不是作为容器,而是作为线程通信的工具。BlockingQueue具有一个特征:当生产者线程试 图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从 BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。 程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通 信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而BlockingQueue就是针对该 模型提供的解决方案。

八、说一说Java同步机制中的wait和notify

wait()、notify()、notifyAll()用来实现线程之间的通信,这三个方法都不是Thread类中所声明的方法, 而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该 通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂 了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写,并且只有采用synchronized实现 线程同步时才能使用这三个方法。 wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对象锁 的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。notifyAll()方法 用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进 而得到CPU的执行。 每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁) 的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU 的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。

九、sleep()和wait()区别

  • sleep()是Thread类中的静态方法,wait()是Object()类中的成员方法。
  • sleep()可以在任何地方使用,wait()只能在同步方法和同步代码块中使用。
  • sleep()不会释放锁,wait()会释放锁,并且需要通过notify()/notifyAll()重新获取锁。

十、notify()和notifyAll()的区别

  • notify() 用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁, 进而得到CPU的执行。
  • notifyAll() 用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。

十一、如何实现子线程先执行,主线程再执行?

启动子线程后立即调用join()方法,则主线程必须等待子线程执行完后再执行。

十二、阻塞线程的方式有几种?

  • 线程调用sleep()方法主动放弃所占用的处理器资源。
  • 线程调用一个非阻塞式IO方法,在该方法返回之前,该线程被阻塞。
  • 线程试图获得一个同步监视器,但该同步监视器正在被其它线程所持有。
  • 线程正在等待某个通知(notify)
  • 程序调用了线程的suspend()方法将该线程挂起,但这个方法容易导致死锁,所以应该尽量避免使 用该方法。

十三、synchronized和Lock的区别

  1. synchronized是java关键字,在JVM层面实现加锁和解锁;Lock是一个接口,在代码层面实现加锁和解锁。
  2. synchronized可以用在代码块上、方法上;Lock只能写在代码里。
  3. synchronized在代码执行完或出现异常时自动释放锁;Lock不会自动释放锁,需要在finally中显示释放锁。
  4. synchronized会导致线程拿不到锁一直等待;Lock可以设置获取锁失败的超时时间。
  5. synchronized无法得知是否获取锁成功;Lock则可以通过tryLock得知加锁是否成功。
  6. synchronized锁可重入、不可中断、非公平;Lock锁可重入、可中断、可公平/不公平,并可以细分读写锁以提高效率。

十四、说一说synchronized的底层实现原理

  1. synchronized作用在代码块时,它的底层是通过monitorenter(监视器入口)、monitorexit(监视器出口)指令来实现的。
  • monitorenter: 每个对象都是一个监视器锁(monitor),当monitor被占用时就会处于锁定状态,线程执行 monitorenter指令时尝试获取monitor的所有权,过程如下: 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor 的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。如果其 他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获 取monitor的所有权。

  • monitorexit: 执行monitorexit的线程必须是objectref所对应的monitor持有者。指令执行时,monitor的进入 数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个 monitor阻塞的线程可以尝试去获取这个monitor的所有权。 monitorexit指令出现了两次,第1次为同步正常退出释放锁,第2次为发生异步退出释放锁。

  1. 方法的同步并没有通过 monitorenter 和 monitorexit 指令来完成,不过相对于普通方法,其常量 池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:

    当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了, 执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执 行期间,其他任何线程都无法再获得同一个monitor对象。

  2. 总结: 两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

十五、synchronized可以修饰静态方法和静态代码块吗?

synchronized可以修饰静态方法,但不能修饰静态代码块。 当修饰静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁。

十六、谈谈ReentrantLock的实现原理

ReentrantLock 是基于 AQS 实现的, AQS 即 AbstractQueuedSynchronizer 的缩写,这个是个内部实 现了两个队列的抽象类,分别是同步队列和条件队列。其中同步队列是一个双向链表,里面储存的是处 于等待状态的线程,正在排队等待唤醒去获取锁,而条件队列是一个单向链表,里面储存的也是处于等 待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾, AQS 所做的就是管理这两个队 列里面线程之间的等待状态-唤醒的工作。

在同步队列中,还存在 2 中模式,分别是独占模式和共享模式,这两种模式的区别就在于 AQS 在唤醒线 程节点的时候是不是传递唤醒,这两种模式分别对应独占锁和共享锁。

AQS 是一个抽象类,所以不能直接实例化,当我们需要实现一个自定义锁的时候可以去继承 AQS 然后重 写获取锁的方式和释放锁的方式还有管理state,而 ReentrantLock 就是通过重写了 AQS 的 tryAcquire 和 tryRelease 方法实现的 lock 和 unlock。

ReentrantLock 结构如下图所示:

image.png

十七、计算机中为什么会出现线程不安全的问题

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。
为了处理这个问题,在CPU里面就有了高速缓存(Cache)的概念。当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。 我举个简单的例子,比如cpu在执行下面这段代码的时候,

t = t + 1;

会先从高速缓存中查看是否有t的值,如果有,则直接拿来使用,如果没有,则会从主存中读取,读取之后会复制一份存放在高速缓存中方便下次使用。之后cup进行对t加1操作,然后把数据写入高速缓存,最后会把高速缓存中的数据刷新到主存中。

这一过程在单线程运行是没有问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的,本次讲解以多核cup为主)。这时就会出现同一个变量在两个高速缓存中的不一致问题了。
例如:
两个线程分别读取了t的值,假设此时t的值为0,并且把t的值存到了各自的高速缓存中,然后线程1对t进行了加1操作,此时t的值为1,并且把t的值写回到主存中。但是线程2中高速缓存的值还是0,进行加1操作之后,t的值还是为1,然后再把t的值写回主存。
此时,就出现了线程不安全问题了。

十八、 volatile关键字是如何保证线程安全问题的

可见性

1.什么是可见性?

意思就是说,在多线程环境下,某个共享变量如果被其中一个线程给修改了,其他线程能够立即知道这个共享变量已经被修改了,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取。
例如我们上面说的,当线程1对t进行了加1操作并把数据写回到主存之后,线程2就会知道它自己工作空间内的t已经被修改了,当它要执行加1操作之后,就会去主存中读取。这样,两边的数据就能一致了。
假如一个变量被声明为volatile,那么这个变量就具有了可见性的性质了。这就是volatile关键的作用之一了。

  1. volatile保证变量可见性的原理

当一个变量被声明为volatile时,在编译成会变指令的时候,会多出下面一行:

0x00bbacde: lock add1 $0x0,(%esp);

这句指令的意思就是在寄存器执行一个加0的空操作。不过这条指令的前面有一个lock(锁)前缀。
当处理器在处理拥有lock前缀的指令时:
在之前的处理中,lock会导致传输数据的总线被锁定,其他处理器都不能访问总线,从而保证处理lock指令的处理器能够独享操作数据所在的内存区域,而不会被其他处理所干扰。
但由于总线被锁住,其他处理器都会被堵住,从而影响了多处理器的执行效率。为了解决这个问题,在后来的处理器中,处理器遇到lock指令时不会再锁住总线,而是会检查数据所在的内存区域,如果该数据是在处理器的内部缓存中,则会锁定此缓存区域,处理完后把缓存写回到主存中,并且会利用缓存一致性协议来保证其他处理器中的缓存数据的一致性。

  1. 缓存一致性协议

刚才我在说可见性的时候,说“如果一个共享变量被一个线程修改了之后,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取”,实际上是这样的:
线程中的处理器会一直在总线上嗅探其内部缓存中的内存地址在其他处理器的操作情况,一旦嗅探到某处处理器打算修改其内存地址中的值,而该内存地址刚好也在自己的内部缓存中,那么处理器就会强制让自己对该缓存地址的无效。所以当该处理器要访问该数据的时候,由于发现自己缓存的数据无效了,就会去主存中访问。

有序性

实际上,当我们把代码写好之后,虚拟机不一定会按照我们写的代码的顺序来执行。例如对于下面的两句代码:

int a = 1;
int b = 2;

对于这两句代码,你会发现无论是先执行a = 1还是执行b = 2,都不会对a,b最终的值造成影响。所以虚拟机在编译的时候,是有可能把他们进行重排序的。
为什么要进行重排序呢?
你想啊,假如执行 int a = 1这句代码需要100ms的时间,但执行int b = 2这句代码需要1ms的时间,并且先执行哪句代码并不会对a,b最终的值造成影响。那当然是先执行int b = 2这句代码了。
所以,虚拟机在进行代码编译优化的时候,对于那些改变顺序之后不会对最终变量的值造成影响的代码,是有可能将他们进行重排序的。

那么重排序之后真的不会对代码造成影响吗?
实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题的。具体请看下面的代码:

public class NoVisibility{ 
    private static boolean ready; 
    private static int number; 
    private static class Reader extends Thread{ 
        public void run(){ 
            while(!ready){ 
                Thread.yield(); 
            } 
            System.out.println(number); 
        } 
    } 
    public static void main(String[] args){
        new Reader().start(); 
        number = 42; ready = true; 
    } 
}

这段代码最终打印的一定是42吗?如果没有重排序的话,打印的确实会是42,但如果number = 42和ready = true被进行了重排序,颠倒了顺序,那么就有可能打印出0了,而不是42。(因为number的初始值会是0).
因此,重排序是有可能导致线程安全问题的。

如果一个变量被声明volatile的话,那么这个变量不会被进行重排序,也就是说,虚拟机会保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。
例如把上面中的number声明为volatile,那么number = 42一定会比ready = true先执行。

不过这里需要注意的是,虚拟机只是保证这个变量之前的代码一定比它先执行,
但并没有保证这个变量之前的代码不可以重排序。之后的也一样。

volatile关键字能够保证代码的有序性,这个也是volatile关键字的作用。

十九、说一说Java中乐观锁和悲观锁的区别

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。Java中悲观锁是通过synchronized关键字和Lock接口实现的。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。相对于对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。

二十、了解Java中的锁升级吗?

JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁。但是在JDK 1.6后,JVM为 了提高锁的获取与释放效率对synchronized 进行了优化,引入了偏向锁和轻量级锁 ,从此以后锁的状 态就有了四种:无锁、偏向锁、轻量级锁、重量级锁。并且四种状态会随着竞争的情况逐渐升级,而且 是不可逆的过程,即不可降级,这四种锁的级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级 锁。如下图所示:

image.png

  1. 无锁

    无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修 改成功。无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突 就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能 修改成功,而其他修改失败的线程会不断重试直到修改成功。

  2. 偏向锁

    初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志 位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放 偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程 ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。 如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。 偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程 在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。 当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和 退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线 程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放 偏向锁的。关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时, 它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态, 则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为 00)的状态。

  3. 轻量级锁

    轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其 他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。 轻量级锁的获取主要由两种情况: 1. 当关闭偏向锁功能时; 2. 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。 一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞 争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存 在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了 锁竞争。 在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获 取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释 放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当 前锁的持有者信息修改为自己。 长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不 了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生 锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现 象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

  4. 重量级锁

    重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚 拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量 级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被 占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。 重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。简言 之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而 这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资。