Java基础-synchronized

synchronize

1、概述

  • java的关键字,用于协调多线程环境下资源竞争问题,是Java中解决并发问题的一种最常用的方法。使用synchronize修饰的方法或者代码块,在同一时间只能有一个线程在执行。

2、原理

  • synchronize实际上是一个**对象锁,是依赖于jvm,当线程访问时需要获取得对象锁才能继续执行否则进入阻塞状态**

3.1 说一说synchronized

  • synchronized是jdk提供的以关键字形式的锁,解决多线程直接的资源竞争问题。可以修饰方法、静态方法、代码块。
  • 原理:在线程运行到关键字修饰的方法或代码块时,会先尝试获取相应的对象锁,获取成功则继续运行,获取失败就会阻塞等待。代码块是通过monitorentermonitorexit指令,方法则是用ACC_SYNCHRONIZED标志该方法为同步方法。
  • Java 6 之前,monitor 的实现依靠操作系统内部的互斥量mutex,需要**用户态到内核态的切换,所以同步操作是重量级操作性能很低。Java 6时Java 虚拟机进行改进,提供了三种不同的 monitor 实现,也就是常说的三种不同的锁:偏向锁(Biased Locking)、轻量级锁重量级锁**,大大改进了其性能。
    • monitor(管程):是由局部于自己的**若干公共变量及其说明和所有访问这些公共变量的过程所组成的软件模块。管程属于编程语言级别**的互斥解决方案
    • mutex(互斥量):操作系统层的**同步原语**,由操作系统实现,使用时需要从用户态切换到内核态
  • 锁升级优化:
    • 多线程下synchronized的加锁是对对象头中markword的变量进行CAS操作
    • 无锁:无锁状态是正常的对象头,存放着hashCode,分代年龄等信息
    • 偏向锁:当只有一个线程访问synchronized临界区时使用偏向锁,通过cas操作将线程id存放到markword中,仅仅一个cas操作锁非常轻
    • 轻量级锁:当有第二个线程访问临界区时,会膨胀成轻量级锁。jvm将markword中的数据复制到栈的锁记录中,并尝试通过cas将markword替换为指向锁记录的指针,成功则获得锁,失败了则尝试通过自旋获取锁,自旋失败则会升级为重量级锁。
    • 重量级锁:markword存放的是指向互斥量的指针

3.2 sychronized 和 ReentrantLock 的区别

  • 他们都是用于处理线程不安全的问题的;
  • synchronized是jvm的实现,ReentranLock是JDK的实现
  • sychronized对加锁释放锁是隐式的,ReentranLock是显式的;
  • synchronized是不响应中断非公平可重入锁,ReentrantLock加锁过程可响应中断可以实现公平锁的可重入锁;
  • ReentrantLock可以condition绑定多个条件
  • 异常时synchronized自动释放锁,ReentranLock需要再finally中手动释放锁

4、为什么引入偏向锁

  • 优化 “单线程重复获取同一把锁” 的场景,在没有多线程竞争的情况下尽量**减少轻量级锁的执行路径。轻量级锁会涉及多个cas操作,频繁的cas操作会造成不必要的性能损耗。偏向锁只需执行一次ThreadId的cas**替换操作,在只有一个线程执行同步快的时候有效提高性能;
  • jdk15标记Deprecated,jdk16默认禁用偏向锁,jdk25删除偏向锁的所有代码实现
    • 偏向锁实际收益与维护成本不匹配,在现代多线程应用中的性能收益有限,且实现复杂、维护成本高,移除后可简化 JVM 代码并减少潜在的性能波动
  • 加锁步骤
    • 判断锁标志是否处于无锁状态并且是否时偏向锁(若已经是偏向锁,检查偏向线程,若是当前线程则不用加锁,若不是在检查偏向线程是否已销毁,若已销毁则重新偏向当前线程;若未销毁则进行锁升级)
    • 修改markword锁状态并使用cas更新线程id到对象头,加锁完成
    • 同步块结束时不会释放锁
  • 撤销偏向锁
    • 偏向锁撤销的最大问题是安全点停顿:撤销过程需要暂停持锁线程,在高并发场景下可能导致短暂的性能波动(延迟增加)
    • 时机:
      • 其他线程尝试获取偏向锁
      • 偏向锁持有线程终止:jvm检测到线程已终止,则撤销偏向锁运行其他线程竞争
      • 调用锁的hashCode()方法
      • 批量撤销机制触发(以类为单位,统计该类所有对象的偏向锁撤销次数)
      • GC时发现线程终止
    • 过程:
      • 1、设置撤销偏锁标志,等待safepoint
      • 2、(此时处于safepoint中,所有线程被暂停)
        • 检查**偏向线程是否存活,若已不存活,设置锁标志为无锁**、未偏向;
        • 若**存活,设置锁标志为00(轻量级**锁),并更新到持有锁线程的栈帧中(该线程仍持有锁)
        • 修改对象头markword,指向线程的lockRecord。

5、为什么引入轻量级锁

  • 针对同步块在没有**多线程竞争的环境**下(多线程交替运行),减少重量级锁使用操作ObjectMonitor所产生的性能消耗。
  • 加锁步骤:
    • 1、同步对象处于**无锁状态**,锁标志位为 “01”,偏向标志位为 “0”
    • 2、创建锁记录,在线程栈的栈帧中创建LockRecord,锁对象MarkWord复制到该锁记录中;
    • 3、使用CAS操作将对象头MarkWord更新为指向LockRecord的指针,并将LockRecord里的owner指针指向对象头的MarkWord
    • 4、成功则关系锁标志为轻量级锁,更新失败时先检查是不是重入锁,如果不是则会尝试自旋,自旋结束仍然获取不到锁,则膨胀成重量级锁
  • 解锁:
    • 1、通过CAS尝试把线程栈帧中复制的LockRecord中的Displaced Mark Word**替换**当前对象头的MarkWord
    • 2、替换失败说明已经膨胀为重量级锁,则在执行完同步块释放锁同时唤醒被挂起的线程
    • 3、轻量锁解锁后,对象位无锁状态

6、偏向锁存在的问题

批量重偏向是以class而不是对象为单位的,与**对象无关,每个class会维护一个偏向锁的撤销计数器,每当该class的对象发生偏向锁的撤销时,该计数器会加一,打到阈值时则会对这个类的对象进行批量重偏向批量撤销**

  • 线程A创建了大量对象并执行了初始的同步操作(操作完线程终止),后来线程B也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。通过**批量重偏向**(bulk rebias)机制解决
  • 在明显多线程竞争剧烈的场景下使用偏向锁是不合适的,通过批量撤销(bulk revoke)机制解决
  • 原理:
    • 1、通过对象头markword的epoch字段进行偏向锁撤销次数统计,
    • 2、以类为单位,在类对象的epoch进行全局计数,阈值到达20时,其他线程尝试获取该类的对象的偏向锁则会进行重偏向而不是锁升级(低于20时是进行锁升级);
    • 3、重偏向的条件除了达到阈值外,还需要类的epoch和对象的epoch不相等,类的epoch表示最新的有效的偏向锁;
    • 4、阈值达到40时,jvm会认为该类的对象不适合使用偏向锁,后续获取锁时直接使用轻量级锁

7、重量级锁加锁过程

  • 加锁时生成一个**ObjectMonitor对象**,将锁对象头的markword指向这个ObjectMonitor,更新锁标志为10。
  • 重量级在于线程竞争锁时需操作ObjectMonitor,而操作过程需要通过系统调用竞争mutex锁,会涉及线程用户态<->内核态的切换,造成资源开销
  • ObjectMonitor对象很多数据:
    • entrylist:候选队列,从cxq 迁移过来 (双向链表,操作时需要竞争mutex)

    • _cxq:竞争队列,所有请求锁的线程首先被放在这个竞争队列中*(单向链表,操作时轻量级只需cas不用竞争mutex)*

      • 在jdk25中,cxq、entrylist合并一个:逻辑辅助维护成本高、优化编译器(C2)的生成效率

        • Rewrite the ObjectMonitor lists #23421

        • 合并后的实现:减少了队列又保持了性能

          1. 冗余一个entry_list_tail的指针,需要唤醒线程去竞争锁时从这个指针唤醒

          2. 之前追加cxq的方式,现在以单向链表的方式追加到entrylist的头(保持了追加效率);

          3. 若entrylist_tail为空了,只从头遍历entrylist(此时为单向链表),并修复为双向链表 并赋值entry_list_tail

    • **waitSet**挂起队列,调用 wait 方法被阻塞的线程被放置在这里

    • **_head**保存markword数据

    • **_owner**当前持有锁线程

  • 加锁是否成功:加锁过程实际就是**对_owner字段cas更新是否成功,这个过程需要mutex**保护;
  • 线程获取锁时:先尝试通过**自旋进行抢占式的获取锁,若获取锁失败则将线程包装成ObjectWaiter并加入_cxq**,然后park自己;
  • 线程释放锁时
    • 首先释放锁,然后根据策略QModeentrylist或者**_cxq获取一个线程进行unpark()唤醒(唤醒后会去尝试获取锁),默认是从_entrylist**的头获取,也就是说是把竞争锁的权限给到下一个线程,而不是直接把锁给它
  • 调用wait()时,首先确认锁是否时重量级锁,如果不是则**先膨胀成重量级锁。wait实际是将线程包装成ObjectWaiter并放到_waitSet**中,然后park();
  • 调用notify时,从**_waitSet中获取第一个对象,根据策略Policy,添加到entrylist、_cxq头/尾**,默认是添加到contentionList的头
  • 重量级锁解锁后,对象回到无锁状态,重新获取锁会进行新一轮的锁膨胀

8、锁的优化

  • 减少锁的粒度:concurrentHashMap
  • 锁分离(读写锁):ReadWriteLock,进行读写分离
  • 锁粗化:for循环中进行加锁,会将锁粗化到for循环外面
  • 锁消除:多余能够判定锁不可能被共享的对象时,会消除这个锁。StringBuffer的append方法在for循环中时会消除掉锁

9、可以使用volatile替代synchronized的场景

  • 前提:
    • 单变量读写操作,如:标志位
    • 不含符合操作,如:i++
  • 场景:
    • 一写多读
    • 多原子性写

10、wait、notify、notifyall的实现

  • wait:将线程包装成objectwait,追加到**_waitSet**,然后线程自己park挂起
  • notify:从**_waitSet获取一个线程,追加到entrylist或者_cxq**,后续和synchronized锁竞争路径相同
    • 当前线程调用notify后,monitorexit退出临界区。jvm从entrylist或者**_cxq**获取一个线程并唤醒,线程随即去 竞争锁
    • 竞争失败则重新回到entrylist或者**_cxq**
    • 隐患:唤醒的线程不一定能获取到锁,可能锁被抢了。可能存在导致线程饿死甚至死锁的情况
  • notifyall:将 _waitSet所有线程全部搬到entrylist或者**_cxq**,后续和notify一样
    • 惊群效应:notifyall把**_waitSet的N个线程都搬到entrylist或者_cxq**,若被外部线程抢锁就空唤醒并回cxq,循环可>N次,造成连续上下文切换开销
  • 解决饿死、惊群的方法:使用Lock + ConditionBlockingQueueCountDownLatch / CyclicBarrier / SemaphoreLockSupport.park/unpark