synchronized原理以及优化

概念

1.java中的关键字,在JVM层面上围绕着内部锁 (intrinsic lock)或者监管锁 (Monitor Lock)的实体建立的,Java利用锁机制实现线程同步的一种方式。

2.synchronized属于隐式锁 ,相比于显示锁如ReentrantLock不需要自己写代码去获取锁和释放锁。

3.synchronized属于可重入锁,当一个线程得到一个对象锁后,再次请求此对象锁时是可以再次得到该对象的锁的。即synchronized块中的synchronized还是能马上获得该锁。

4.synchronized为非公平锁,即多个线程去获取锁的时候,会直接去尝试获取,如果能获取到,就直接获取到锁,获取不到的话进入等待队列。

5.jdk1.6之前,synchronized属于重量级锁(悲观锁) ,jdk1.6之后被进行了大幅度优化,支持锁升级制度缓解加锁和解锁造成的性能费,锁的级别采用: 偏向锁 -> 轻量级锁 -> 重量级锁。 加锁的目的: 序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问),不过有一点需要区别:当多个线程执行一个方法的时候,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全的问题

使用方法

synchronized的使用方式主要有两种,分别是:

1.对方法加锁( 对普通方法加锁,分为普通方法和静态方法): a.对普通方法加锁,即为对当前实例对象加锁,同一个类创建的不同对象调用该方法所获取的是不同的锁,所以不会有影响。 b.对静态方法加锁,静态方法属于类,同一个类创建的不同对象调用该方法时是互斥的,此时的锁对象是class对象。

2.对方法块加锁:

锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

synchronized保证的特性

  1. 原子性: synchronized依靠两个字节码指令monitorentermonitorexit,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问

  2. 可见性: JMM(Java内存模型)规定,内存主要分为主内存和工作内存两种,每个线程拥有不同的工作内存,线程工作时会从主内存中拷贝一份变量到工作内存中。代码执行后,有时工作内存中的变量无法及时刷新到主内存中,或者工作内存无法及时获取主内存的最新值,导致共享变量在不同线程间处于不可见性,由此JMM对synchronized做了2条规定:

    a.线程解锁前,必须把变量的最新值刷新到主内存中。

    b.线程加锁时,先清空工作内存中的变量值,从主内存中重新获取最新值到工作内存中

  3. .有序性: 有时候编译器和处理器为了提升代码效率,会进行指令重排序,但是as-if-serial规定无论怎么重排序,单线程程序的执行结果都不能被改变,而synchronized保证了被修饰的程序在同一时间内只能被同一线程访问, 所以其也算是保证了有序性,但synchronized实际上并不是禁止了被修饰的代码指令重排序。

底层原理

synchronized实现对代码块加锁

synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现。

synchronized对代码块加锁需要依靠两个指令 monitorenter monitorexit

1.在进入代码块前执行 monitorenter 指令

2.在离开代码快前执行 monitorexit 指令
monitorenter: 每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行 monitorenter指令时尝试获取monitor的所有权,

过程如下: a. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor 的所有者; b. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1; c. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝 试获取monitor的所有权;

monitorexit: 执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减 1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去 获取这个 monitor 的所有权。

synchronized实现对方法加锁

对方法的加锁并不依靠 monitorentermonitorexit 指令,JVM可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否是同步方法。当该方法被调用时,调用指令会检查方法的 ACC_SYNCHRONIZED 是否被设置,如果 ACC_SYNCHRONIZED 被设置了,则执行线程率先持有 monitor锁,然后再执行方法,执行结束(或者发生异常并抛到方法之外时)时释放monitor。

synchronized加锁加在对象上,对象是如何记录锁状态的呢?

锁状态是被记录在每个对象 的对象头(Mark Word)

Mark Word:存储对象自身的运行时数据,如对象的HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。而且Mark Word中的LockWord存储了指向monitor的起始地址。

Monior:在java中每个对象天生就带了一把内部锁或者Monitor锁,Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,

锁升级

JDK1.6之前synchronize是标准的重量级锁(悲观锁),JDK1.6之后进行了大幅度优化,支持锁升级制度缓解加锁和解锁造成的性能浪费,锁的状态总共有四种,无锁、偏向锁、轻量级锁和重量级锁。 随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,并且锁只能升级不能降级。

偏向锁

偏向锁的思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时,Mark Word的结构也变为偏向锁结构 ,当这个线程再次请求锁时,无需再做任何同步操作即可再次获取锁,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以对于没有锁竞争的场合,偏向锁有很好的优化效果,但是在有多线程竞争锁 的场合,偏向锁就失效了,这种场合下不应该使用偏向锁,否则会得不偿失,偏向锁失败后,将会优先升级为轻量级锁 基本工作过程:

当线程A访问代码块并获取锁对象时,会通过CAS在Mark Word中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后再次获取锁的时候,需要比较当前线程的 threadID 和 Mark Word 中的threadID是否一致,

如果一致,则无需使用CAS来加锁、解锁;

如果不一致,则是因为有其他线程如线程b 来竞争该锁,而偏向锁时不会主动释放锁,因此 Mark Word 存储的还是 线程a 的threadID,那么需要查看 Mark Word 中记录的 线程a 是否存活

  • 如果没有存活,那么锁对象被重置为无锁状态,线程b 可以竞争将其设置为偏向锁;
  • 如果存活,那么立刻查找线程a的栈帧信息,如果还是需要继续持有这个锁, 那么暂停当前线程a,撤销偏向锁,升级为轻量级锁,如果 线程不再使用该锁,那么将锁状态设为无锁状态,重新偏向新的线程。

在java中偏向锁是默认开启的,绝大多数 情况下,对于加锁的程序大多都会有两个以上的线程去竞争,如果开启偏向锁,反而会加剧锁的资源消耗,可以通过jvm参数启动或关闭偏向锁:

ini 复制代码
-XX:-UseBiasedLocking = false
    //偏向锁的启动延迟默认为5秒,可以取消这个延迟:
XX:BiasedLockingStartUpDelay=0

轻量级锁

轻量级锁是由偏向锁升级而来,它考虑的情况是竞争锁的线程不多,而且线程持有锁的时间也不长的情景。 因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,性能的浪费就太大了,因此这个时候就干脆不阻塞这个线程,让它CAS自旋等待锁释放。

轻量级锁能够提升程序性能的依据是"对绝大部分的锁,在整个同步周期内都不存在竞争", 轻量级锁在加锁过程中,用到了自旋锁来避免因为多线程的竞争而把线程马上在操作系统层面挂起的情况。

例如:线程a 获取轻量级锁时会先把锁对象的 Mark Word 复制一份到 线程a 的栈帧中存储锁记录的 LockRecord 中,然后使用cas操作把对象头的 Mark Word 的内容替换为 线程a 的 LockRecord 地址,并将Lock record里的owner指针指向对象的 Mark Word,

如果在 线程a 复制对象头的同时(在 线程a cas之前),线程b 也准备获取锁,复制了对象头到 线程b 的锁记录空间中,但是在 线程b cas 的时候,发现 线程a 已经把对象头替换了,则 线程b 获取锁失败,那么 线程b 就尝试使用自旋锁来等待 线程a 释放锁。

自旋锁

*虚拟机为了避免多线程的竞争而使线程马上在操作系统层面挂起,还会进行一项称为自旋锁的优化手段 *这,是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程过于浪费性能,因此自旋锁会假设在较短的时间内,当前的线程便可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环原地等待(自旋)

默认情况下自旋的次数是 10 次,

在经过若干次循环后,如果得到锁,就顺利进入临界区。

但是如果自旋次数到了持锁线程还还没有释放锁,或者持锁线程还在执行,下个线程还在自旋等待,这时又有第三个线程过来竞争这个锁,那就会将线程在操作系统层面挂起,这就是自旋锁提升效率的优化方式。如果自旋结束还是成功获取锁,则升级为重量级锁了。

自适应自旋锁

在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据同一个锁上一次自旋的时间和拥有锁线程的状态来决定,目的是最大的提高处理器资源利用率。

  • 对于某个锁,如果线程通过自旋成功获得过该锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
  • 如果某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

重量级锁

当轻量级锁膨胀到重量级锁之后,意味着线程只能被真正的挂起阻塞,然后等待被唤醒。重量级锁的实现方式即为利用每个对象都用的 monitor 内置锁。

各种锁对比

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比只是存在纳秒级的差距 如果线程间存在竞争,会带来额外的锁撤销的消耗 只有一个线程访问同步块或者同步方法
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 若线程长时间竞争不到锁,自旋会消耗 CPU 性能 线程交替执行同步块或者同步方法,追求响应时间,锁占用时间很短,阻塞还不如自旋的场景
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,锁占用时间较长
相关推荐
高兴达4 分钟前
RPC--Netty客户端实现
java·spring·rpc
重庆小透明30 分钟前
力扣刷题记录【1】146.LRU缓存
java·后端·学习·算法·leetcode·缓存
lang2015092836 分钟前
Reactor操作符的共享与复用
java
TTc_1 小时前
@Transactional事务注解的批量回滚机制
java·事务
博观而约取1 小时前
Django 数据迁移全解析:makemigrations & migrate 常见错误与解决方案
后端·python·django
wei_shuo2 小时前
飞算 JavaAI 开发助手:深度学习驱动下的 Java 全链路智能开发新范式
java·开发语言·飞算javaai
寻月隐君2 小时前
Rust 异步编程实践:从 Tokio 基础到阻塞任务处理模式
后端·rust·github
GO兔2 小时前
开篇:GORM入门——Go语言的ORM王者
开发语言·后端·golang·go
Sincerelyplz2 小时前
【Temproal】快速了解Temproal的核心概念以及使用
笔记·后端·开源
爱上语文2 小时前
Redis基础(6):SpringDataRedis
数据库·redis·后端