【多线程进阶】synchronized的总结

🎉🎉🎉点进来你就是我的人了
博主主页:🙈🙈🙈戳一戳,欢迎大佬指点!

欢迎志同道合的朋友一起加油喔🦾🦾🦾


目录

前言

一、synchronized的特性

1.1 原子性

1.2 可见性

1.3 有序性

1.4 可重入性

二、synchronized的用法

2.1 修饰方法

2.2 修饰静态方法

2.3 修饰代码块

2.3.1 成员锁

2.3.2 this

2.3.3 .class

三、synchronized锁的实现

3.1 同步方法

3.2 同步代码块

四、synchronized锁的底层实现

五、JVM对synchronized的优化

synchronized使用锁策略:

synchronized加锁工作过程

1) 偏向锁

2) 轻量级锁

3) 重量级锁

4)  锁消除

5) 锁粗化



前言

🐳🐳🐳如果某一个资源被多个线程共享,为了避免因为资源抢占导致资源数据错乱,我们需要对线程进行同步,那么synchronized就是实现线程同步的关键字,可以说在并发控制中是必不可少的部分,今天就来看一下synchronized的使用和底层原理。


一、synchronized的特性

1.1 原子性

所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。

被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。

注意!面试时经常会问比较synchronized和volatile,它们俩特性上最大的区别就在于原子性,volatile不具备原子性。

1.2 可见性

可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。

synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。

1.3 有序性

有序性值程序执行的顺序按照代码先后执行。

synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

1.4 可重入性

synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁


二、synchronized的用法

synchronized 包括三种用法:

  • 修饰方法
  • 修饰静态方法
  • 修饰代码块

2.1 修饰方法

        多线程环境下,每次只能有一个线程访问该方法。

示例:

public synchronized void increase() {
    i++;
}

2.2 修饰静态方法

        当 synchronized 作用于静态方法时,其锁住的就是当前整个类的 class 对象。

public static synchronized void increase() {
    i++;
}

需要注意的是:

        如果一个线程 A 调用一个实例对象的非 static静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的 static静态 synchronized 方法,是允许的,不会发生互斥现象,

        因为访问静态 synchronized 方法锁住的是当前类的 class 对象,而访问非静态 synchronized 方法锁住的是当前实例对象二者的锁并不一样,所以不冲突

2.3 修饰代码块

        在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方法对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。

        我们可以使用如下几种对象来作为锁的对象:

2.3.1 成员锁

        锁住的对象是变量a1指向的对象:

public Object synMethod(Object a1) {
    synchronized(a1) {
        // 操作
    }
}

2.3.2 this

        this 代表当前实例对象,锁住的是当前实例:

synchronized(this) {
    for (int j = 0; j < 100; j++) {
		i++;
    }
}

2.3.3 .class

        锁住的是当前类的 class 对象锁:

synchronized(AccountingSync.class) {
    for (int j = 0; j < 100; j++) {
        i++;
    }
}
  1. 使用静态对象作为锁:通过声明 private static Object object = new Object();,你创建了一个类级别的锁对象,所有的 MyThread 实例都会共享这个锁。只有拿到这个锁的线程才能进入 synchronized(object) 创建的临界区,从而实现了 ticket 的互斥访问。这样可以保证每次只有一个线程在卖票,避免了重复售票。

  2. 使用 MyThread.class 作为锁:类似地,MyThread.class 是一个全局唯一的对象,可以作为一个全局的锁。所有的 MyThread 实例都会竞争同一个锁,从而实现对 ticket 的互斥访问。这也能避免重复售票的问题。

  3. 使用 this 作为锁:this 关键字代表当前对象。如果你使用 this 作为锁,那么每一个 MyThread 实例都会有它自己的锁,而不是共享一个锁。这样,各个线程可能同时进入 synchronized 代码块,同时修改 ticket 的值,从而可能出现重复售票的问题。

总结:选择合适的同步锁对于保证数据的一致性和避免线程安全问题非常重要。在多个线程需要操作同一个共享资源的情况下,通常需要选择一个所有线程都能访问到,且在整个应用中都是唯一的对象作为锁。


三、synchronized锁的实现

synchronized有两种形式上锁,一个是对方法上锁,一个是构造同步代码块他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作

3.1 同步方法

首先来看在方法上上锁,我们就新定义一个同步方法然后进行反编译,查看其字节码:

可以看到在add方法的flags里面多了一个ACC_SYNCHRONIZED标志,这标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞住,知道该锁被释放。

3.2 同步代码块

我们新定义一个同步代码块,编译出class字节码,然后找到method方法所在的指令块,可以清楚的看到其实现上锁和释放锁的过程,截图如下

从反编译的同步代码块可以看到同步块是由monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。

但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是23行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。


四、synchronized锁的底层实现

在理解锁实现原理之前先了解一下Java的对象头和Monitor,在JVM中,对象是分成三部分存在的:对象头、实例数据、对其填充。

实例数据和对其填充与synchronized无关,这里简单说一下(我也是阅读《深入理解Java虚拟机》学到的,读者可仔细阅读该书相关章节学习)。实例数据存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐对其填充不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐

对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark Word 和 Class Metadata Address组成,其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息Class Metadata Address是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例

锁也分不同状态,JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。

每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁计数器
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

该段摘自:https://blog.csdn.net/javazejian/article/details/72828483   ObjectMonitor中有两个队列_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。   monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因


五、JVM对synchronized的优化

从最近几个jdk版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,新增了两个锁状态,通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。

synchronized使用锁策略:

  1. 既是悲观锁,也是乐观锁,开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 既是轻量级锁,也是重量级锁(自适应),开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁来实现
  4. 不是读写锁
  5. 是非公平锁
  6. 是可重入锁

synchronized加锁工作过程

synchronized在加锁的时候要经历几个阶段:

  1. 无锁(没加锁)
  2. 偏向锁(刚开始加锁,未产生竞争的时候)
  3. 轻量级锁(产生锁竞争了)
  4. 重量级锁(锁竞争的更激烈)

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级

1) 偏向锁

第一个尝试加锁的线程, 优先进入偏向锁状态.

偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.

如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.

但是该做的标记还是得做的, 否则无法区分何时需要真正加锁. 这个过程类似于单例模式中的“懒汉模式”,必要时再加锁,节省开销

2) 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).

此处的轻量级锁就是通过 CAS 来实现.

  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)

  • 如果更新成功, 则认为加锁成功

  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.

因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.

也就是所谓的 “自适应”

3) 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁 此处的重量级锁就是指用到内核提供的 mutex .

  • 执行加锁操作, 先进入内核态.

  • 在内核态判定当前锁是否已经被占用 如果该锁没有占用, 则加锁成功, 并切换回用户态.

  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.

  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.

4)  锁消除

    编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.

下面append等方法,都是带有synchronized,如果上述代码都只是在同一个线程中执行,此时就没必要加锁了,JVM就把锁去掉了(目的:是为了节省开销)

package sync优化;
 
public class 锁消除 {
 
    public static void main(String[] args) {
        //局部变量只有当前方法执行的线程持有(不可能有其他线程持有)
        //也就不存再线程安全问题:jvm给append中synchronized加锁释放锁
        // 优化方案,就是“锁消除”=>不加锁
        StringBuffer sb = new StringBuffer();
        sb.append("a");
        sb.append("b");
        sb.append("c");
        System.out.println(sb.toString());
    }
}
 
 

StringBuffer是一个线程安全的类,它的每个方法都是同步的,可以保证在多线程环境中访问时不会发生数据竞争的情况。具体来说,当多个线程同时访问同一个StringBuffer对象时,只有一个线程能够获得锁,其他线程会被阻塞,直到获得锁的线程释放锁。

相比之下,StringBuilder是一个非线程安全的类,它的方法没有加同步锁,因此在多线程环境中使用可能会产生数据竞争,导致程序出现异常或者得到错误的结果。

为了保证线程安全,如果在多线程环境中需要操作字符串,应该使用StringBuffer而不是StringBuilder。但是需要注意的是,StringBuffer的每个方法都会加锁,会导致性能有所下降。因此,如果在单线程环境中操作字符串,建议使用StringBuilder,因为它的性能更好。

需要注意的是,从Java 5开始,JVM中的字符串常量池中的字符串是共享的,因此在处理字符串时,应该尽量避免使用StringBuffer和StringBuilder来拼接字符串,而应该使用String的“+”操作符或者String.join()方法,这样可以减少对象的创建和销毁,提高程序的性能

5) 锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

锁的粒度:表示synchronized包含的代码范围是大还是小,范围越大,粒度越粗;范围越小,粒度越细

锁的粒度细了,能够更好的提高线程的并发,但是也会增加“加锁解锁”的次数

 锁粗化是一种优化技术,它的主要思想是尽可能减少锁的获取和释放的次数,从而提高程序的性能。实际编写代码的过程中,我们通常希望尽可能将代码拆分成较小的代码块,以便于并发执行,从而提高程序的性能

在使用synchronized关键字时,每次获取锁和释放锁都会带来一定的开销,因此如果需要重复执行的代码块中包含了多个synchronized关键字,那么就可以考虑将这些代码块合并成一个大的代码块,从而减少锁的获取和释放的次数,提高程序的性能。这就是锁粗化的优化技术。

需要注意的是,锁粗化并不适用于所有情况。如果合并代码块会导致锁的持有时间变长,或者合并后的代码块不具有独立的业务逻辑,那么锁粗化可能会降低程序的性能。因此,在使用锁粗化技术时需要进行评估和测试,以确保它能够真正地提高程序的性能。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

书生-w

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

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

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

打赏作者

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

抵扣说明:

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

余额充值