深入解析Java多线程与并发编程(三)(Java内置锁以及synchromized线程同步机制)

前言

本期将介绍Java内置锁,了解synchromized关键字的核心原理以及介绍一下线程间通信。

Java内置锁的核心原理

Java中每个对象都可以用作锁,这些锁称为内置锁,每个 Java 对象在内存中除了存储自身数据外,还隐含包含一个 “锁标记”(由 JVM 管理)。当多个线程需要访问同一个对象的共享状态(例如对象的成员变量)时,可以通过这个锁来协调线程的执行顺序,避免数据竞争。Java内置锁是一个互斥锁,这就意味着最多只有一个线程能够获得该锁。

synchronized关键字

synchronized关键字的底层实现就是基于对象的内置锁实现的,接下来,我们详细介绍一下,
在Java中,线程同步使用最多的方法是使用synchronized关键字,使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。临界区代码段指的是访问临界资源的代码,临界资源指的是任何时刻只能有一个线程访问的资源。

synchronized同步方法

synchronized关键字是Java的保留字,当使用synchronized关键字修饰一个方法的时候,该方法被声明为同步方法,关键字synchronized的位置处于同步方法的返回类型之前。具体的例子如下:

//同步方法
public synchronized  void selfPlus()
{
    amount++; 
}

synchronized同步块

对于小的临界区,我们直接在方法声明中设置synchronized同步关键字,可以避免竞态条件的问题。但是对于较大的临界区代码段,为了执行效率,最好将同步方法分为小的临界区代码段。通过下面这个例子来具体讲述:

public class TwoPlus {
    
    private int sum1 = 0;
    private int sum2 = 0;
    //同步方法
    public synchronized void plus(int val1, int val2){
         //临界区代码段
         this.sum1 += val1;
         this.sum2 += val2;
   }
}

在以上代码中,临界区代码段包含对两个临界区资源的操作,使用synchronized对plus(int val1,int val2)进行同步保护之后,进入临界区代码段的线程拥有sum1和sum2的操作权,并且是全部占用。一旦线程进入,当线程在操作sum1而没有操作sum2时,也将sum2的操作权白白占用。
为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。synchronized同步块的写法是:

synchronized(syncObject) //同步块而不是方法
{
     //临界区代码段的代码块
}

由于每一个Java对象都有一把监视锁,因此任何Java对象都能作为synchronized的同步锁。使用synchronized同步块对上面的TwoPlus类进行吞吐量的提升改造,具体的代码如下:

public class TwoPlus{

    private int sum1 = 0;
    private int sum2 = 0;
    private Integer sum1Lock = new Integer(1); // 同步锁一
    private Integer sum2Lock = new Integer(2); // 同步锁二

    public void plus(int val1, int val2){
       //同步块1
        synchronized(this.sum1Lock){
            this.sum1 += val1;
        }
       //同步块2
       synchronized(this.sum2Lock){
            this.sum2 += val2;
        }
    }
}

synchronized方法和synchronized同步块有什么区别呢?
总体来说,synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多个线程并发访问的。

静态的同步方法

在Java世界里一切皆对象。Java有两种对象:Object实例对象和Class对象。每个类运行时的类型信息用Class对象表示,它包含与类名称、继承关系、字段、方法有关的信息。JVM将一个类加载入自己的方法区内存时,会为其创建一个Class对象,对于一个类来说其Class对象是唯一的。Class类没有公共的构造方法,Class对象是在类加载的时候由Java虚拟机调用类加载器中的defineClass方法自动构造的,因此不能显式地声明一个Class对象。所有的类都是在第一次使用时被动态加载到JVM中的(懒加载)​,其各个类都是在必需时才加载的。这一点与许多传统语言(如C++)都不同,JVM为动态加载机制配套了一个判定一个类是否已经被加载的检查动作,使得类加载器首先检查这个类的Class对象是否已经被加载。如果尚未加载,类加载器就会根据类的全限定名查找.class文件,验证后加载到JVM的方法区内存,并构造其对应的Class对象。
**使用synchronized关键字修饰static方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。**为了以示区分,这里将Object对象的监视锁叫作对象锁,将Class对象的监视锁叫作类锁。

package com.crazymakercircle.plus;
// 省略import
public class SafeStaticMethodPlus
{   //静态的临界区资源
    private static Integer amount = 0;
    //使用synchronized关键字修饰 static方法
    public static synchronized void selfPlus()
    {
        amount++;
     }
}

通过synchronized关键字所抢占的同步锁什么时候释放呢?一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题。

Java对象结构与内置锁

Java对象(Object实例)结构包括三部分:对象头、对象体和对齐字节。
在这里插入图片描述

Mark Word的结构信息

这里我们主要介绍Mark Word标记字,Mark Word(标记字)字段主要用来表示对象的线程锁状态,另外还可以用来配合GC存放该对象的hashCode。
Java内置锁涉及很多重要信息,这些都存放在对象结构中,并且存放于对象头的Mark Word字段中。ava内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁
我们看一下不同锁状态下的Mark Word字段结构,JVM将Mark Word最低两个位设置为Java内置锁状态位。我们以32为JVM举例:
在这里插入图片描述

无锁、偏向锁、轻量级锁和重量级锁

在JDK 1.6版本之前,所有的Java内置锁都是重量级锁。重量级锁会造成CPU在用户态和核心态之间频繁切换,所以代价高、效率低。JDK 1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁的实现。内置锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能再降级成偏向锁。这种能升级却不能降级的策略,其目的是提高获得锁和释放锁的效率。

无锁状态

Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态(无线程竞争它)​,这时偏向锁标识位是0,锁状态是01。
在这里插入图片描述

偏向锁状态

偏向锁是指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID,内置锁会将该线程当作自己的熟人,当这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下效率非常高。
在这里插入图片描述
至于为什么需要偏向锁,在实际场景中,如果一个同步块(或方法)没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还要去通过CAS操作去判断锁是否被占用,甚至可能因误判竞争而升级为重量级锁(导致线程阻塞),那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁的概念。

轻量级锁状态

当有两个线程开始竞争这个锁对象时,情况就发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。
在这里插入图片描述
当偏向锁被其他线程尝试抢占时,会升级为轻量级锁。此时,抢占线程会通过自旋方式尝试获取锁,而非直接阻塞,从而提升性能。自旋机制的核心在于:如果锁持有者能在短时间内释放锁,等待线程就无需进行内核态与用户态之间的切换来进入阻塞状态,只需短暂等待(自旋)即可在锁释放后立即获取,避免了线程切换的开销。

然而,自旋会消耗CPU资源。为避免线程因长时间自旋而浪费CPU,需要设置自旋的最大等待时间。自JDK 1.6起,JVM引入了适应性自旋锁机制,其自旋时间不再固定,而是根据同一锁上的前次自旋时间及锁持有者的状态动态调整:若自旋成功,则增加下次自旋次数;若失败,则减少自旋次数。

当锁持有者的执行时间超过最大自旋等待时间仍未释放锁时,争用线程将停止自旋并进入阻塞状态,此时锁将膨胀为重量级锁。

重量级锁状态

重量级锁会让其他申请的线程之间进入阻塞,性能降低。重量级锁也叫同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程。
在这里插入图片描述

对比

总结一下synchronized的执行过程,大致如下:

  1. 当线程尝试获取锁时,JVM首先检查锁对象Mark Word中的偏向锁标识(biased_lock)是否为1,以及锁标志位(lock)是否为01。若两者均满足,则确认该锁对象处于可偏向状态。
  2. 确定锁对象可偏向后,JVM进一步检查Mark Word中的线程ID是否与当前抢锁线程ID一致。若匹配,则表明该线程已持有偏向锁,可直接进入临界区执行代码。
  3. 若Mark Word中的线程ID与抢锁线程不匹配,JVM将通过CAS操作竞争锁。若竞争成功,则将Mark Word中的线程ID更新为当前线程,并将偏向标志位设为1、锁标志位设为01,此时锁对象进入偏向锁状态,线程可进入临界区。
  4. 若CAS竞争失败,表明存在锁竞争,JVM将撤销偏向锁并将其升级为轻量级锁。
  5. JVM尝试使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针。若替换成功,线程获得锁;若失败,则表明存在其他线程竞争,JVM将进行CAS自旋尝试。若自旋成功,锁对象仍保持轻量级锁状态。
  6. 若CAS自旋失败,轻量级锁将膨胀为重量级锁,后续等待锁的线程将进入阻塞状态。
    总结来说,偏向锁适用于无竞争场景;当出现第二个竞争线程时,偏向锁将升级为轻量级锁;若竞争加剧,轻量级锁在CAS自旋达到阈值后将进一步升级为重量级锁。

在这里插入图片描述

线程间通信

线程的通信可以被定义为:当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以避免无效的资源争夺。线程间通信的方式可以有很多种:等待-通知、共享内存、管道流。每种方式用不同的方法来实现,这里首先介绍等待-通知的通信方式。
Java语言中“等待-通知”方式的线程间通信使用对象的wait()、notify()两类方法来实现。每个Java对象都有wait()、notify()两类实例方法,并且wait()、notify()方法和对象的监视器是紧密相关的。

监视器

我们需要介绍一下监视器,在 Java 中,监视器是JVM 实现多线程同步和协作的核心底层机制。每个 Java 对象在创建时,JVM 会自动为其关联一个唯一的监视器,用于管理线程对该对象关联资源的访问。监视器的核心由以下三个组件构成,分别管理不同状态的线程:

  • Owner:记录当前持有监视器锁的线程 ID。若为 null,表示监视器未被任何线程占用。
  • EntryList:存储所有等待获取监视器锁的线程(状态为 BLOCKED)。这些线程因无法立即获取锁(锁被其他线程占用)而进入此队列,等待锁释放后重新竞争。
  • WaitSet:存储调用过 wait() 方法的线程。这些线程因等待某个条件而主动释放锁,并进入此队列,直到其他线程调用 notify() 或 notifyAll() 唤醒它们。

对象的wait()方法

对象的wait()方法的主要作用是让当前线程阻塞并等待被唤醒。

synchronized(locko)
{      
     //同步保护的代码块
     locko.wait();
     ...
}

Object类中的wait()方法有三个版本:
(1)void wait()
这是一个基础版本,当前线程调用了同步对象locko的wait()实例方法后,将导致当前的线程等待,当前线程进入locko的监视器WaitSet,等待被其他线程唤醒。。
(2)void wait(long timeout)
这是一个限时等待版本,当前的线程等待,等待被其他线程唤醒,或者指定的时间timeout用完,线程不再等待。
(3)void wait(long timeout,int nanos)
这是一个高精度限时等待版本,其主要作用是更精确地控制等待时间。参数nanos是一个附加的纳秒级别的等待时间,从而实现更加高精度的等待时间控制。

wait()方法的核心原理:

(1)当线程调用了locko(某个同步锁对象)的wait()方法后,JVM会将当前线程加入locko监视器的WaitSet(等待集)​,等待被其他线程唤醒。
(2)当前线程会释放locko对象监视器的Owner权利,让其他线程可以抢夺locko对象的监视器。
(3)让当前线程等待,其状态变成WAITING。

对象的notify()方法

对象的notify()方法的主要作用是唤醒在等待的线程。notify()方法与对象监视器紧密相关,调用notify()方法时也需要放在同步块中。notify()方法的调用方法如下:

synchronized(locko)
{      
     //同步保护的代码块
     locko.notify();
     ...
}

notify()方法有两个版本:
版本一:void notify()notify()方法的主要作用为:locko.notify()调用后,唤醒locko监视器等待集中的第一条等待线程;被唤醒的线程进入EntryList,其状态从WAITING变成BLOCKED。
版本二:void notifyAll()locko.notifyAll()被调用后,唤醒locko监视器等待集中的全部等待线程,所有被唤醒的线程进入EntryList,线程状态从WAITING变成BLOCKED。

notify()方法的核心原理

(1)当线程调用了locko的notify()方法后,JVM会唤醒locko监视器WaitSet中的第一条等待线程。
(2)当线程调用了locko的notifyAll()方法后,JVM会唤醒locko监视器WaitSet中的所有等待线程。
(3)等待线程被唤醒后,会从监视器的WaitSet移动到EntryList,线程具备了排队抢夺监视器Owner权利的资格,其状态从WAITING变成BLOCKED。
(4)EntryList中的线程抢夺到监视器的Owner权利之后,线程的状态从BLOCKED变成Runnable,具备重新执行的资格。

总结

本期介绍了Java内置锁和synchronized的核心底层原理,下期将介绍CAS原理与JUC原子类。

写在文末

 有疑问的友友,欢迎在评论区交流,笔者看到会及时回复

请大家一定一定要关注!!!
请大家一定一定要关注!!!
请大家一定一定要关注!!!
友友们,你们的支持是我持续更新的动力~

创作不易,求关注,点赞,收藏,谢谢~
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wxchyy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值