我是靠谱客的博主 曾经玉米,最近开发中收集的这篇文章主要介绍详解并发编程之synchronized关键字, 学到就是赚到前言一、synchronized的使用二、对象内存布局与锁状态存储三、锁状态总结,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

文章目录

  • 前言
  • 一、synchronized的使用
    • 思考题
    • synchronized关键字
  • 二、对象内存布局与锁状态存储
    • 对象在内存的布局
    • 对象头
    • 使用JOL工具查看内存布局
    • Mark Word
      • Mark Word结构
  • 三、锁状态
    • 无锁
    • 偏向锁
      • 偏向锁延迟偏向
      • 偏向匿名状态
      • 偏向锁撤销
        • 偏向锁撤销为无锁
        • 偏向锁撤销为轻量级锁
        • 偏向锁撤销为重量级锁
    • 轻量级锁
    • 重量级锁
      • Monitor
  • 总结


前言

大家好, 这里是Yve菌, 今天给大家带来一期synchronized关键字相关的知识, synchronized关键字也是并发专题当中比较复杂和重要的一部分, 那么废话不多说, 开始我们今天的内容吧!


一、synchronized的使用

思考题

在介绍synchronized关键字之前我们思考一个问题: 两个线程对一个初始值为0的静态变量一个自增, 一个自减, 各做5000次, 那么他最后的结果会是0吗?

public class SyncDemo {
   
    private static int counter = 0;

    public static void increment() {
        counter++;
    }

    public static void decrement() {
        counter--;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        //思考: counter=?
        log.info("{}", counter);
    }
}

经过测试我们发现, 上面代码的结果可能是正数也可能是负数, 但是为0的概率非常低, 这是为什么呢?

这是因为java多线程对变量进行操作时并不是原子操作, 有可能出现t1修改完变量没来得及进行存储, t2就开始对变量修改并更新, 这样一来就会出现很严重的并发问题.

原子操作问题

synchronized关键字

synchronized是在java并发中经常使用的一种关键字, 他可以用来修饰方法以及代码块, 也就是给对象, 方法, 代码块进行加锁. 当他锁定了一个类或变量之后, 同一时间最多只能有一个线程来执行这段代码, 其他线程只能进行排队等候.
加锁
回到我们刚才的思考题, 就是因为在同一时间两个线程一起在操作共享变量才导致了原子性问题, 我们可以添加synchronized关键字来解决.

方法一:

public static synchronized void increment() {
    counter++;
}

public static synchronized void decrement() {
    counter--;
}

方法二:

private static String lock = "";

public static void increment() {
    synchronized (lock){
        counter++;
    }
}

public static void decrement() {
    synchronized (lock) {
        counter--;
    }
}

synchronized实际上就是用对象锁保证了这两个操作的原子性
解决原子性

二、对象内存布局与锁状态存储

我们的锁信息是保存在对象当中的对象头中, 因此我们必须要了解对象在内存中的布局.

对象在内存的布局

Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。

实例数据:存放类的属性数据信息,包括父类的属性信息;

对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
对象内存布局

对象头

对象头中包含了三个信息:

  • Mark Word
    用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。

  • Klass Pointer
    对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。

  • 数组长度
    如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节

对象头

使用JOL工具查看内存布局

在java中有一个依赖包可以查看普通java对象的内部布局工具JOL(JAVA OBJECT LAYOUT),使用此工具可以查看new出来的一个java对象的内部布局,以及一个普通的java对象占用多少字节。

首先我们引入maven依赖

<!-- 查看Java 对象布局、大小工具 -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>

使用方法

public static void main(String[] args) throws InterruptedException {
    Object obj = new Object();
    //查看对象内部信息
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

显示结果:
在这里插入图片描述

  • OFFSET:偏移地址,单位字节;
  • SIZE:占用的内存大小,单位为字节;
  • TYPE DESCRIPTION:类型描述,其中object header为对象头;
  • VALUE:对应内存中当前存储的值,二进制32位;

我们之前就提到了synchronized加锁的信息是保存在对象头中的, 那么他具体是怎么保存的呢?

锁状态被记录在每个对象头的Mark Word中!

Mark Word

Hotspot通过markOop类型实现Mark Word,具体实现位于markOop.hpp文件中。由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。

简单点理解就是:MarkWord 结构搞得这么复杂,是因为需要节省内存,让同一个内存区域在不同阶段有不同的用处。

Mark Word结构

//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

。。。。。。
//    [JavaThread* | epoch | age | 1 | 01]       lock is biased toward given thread
//    [0           | epoch | age | 1 | 01]       lock is anonymously biased
//
//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used by markSweep to mark an object
  • hash: 保存对象的哈希码。运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。
  • age: 保存对象的分代年龄。表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • biased_lock: 偏向锁标识位。由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • lock: 锁状态标识位。区分锁状态,比如11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • JavaThread*: 保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
  • epoch: 保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。

32位JVM中对象头结构:
在这里插入图片描述
64位JVM中对象头结构:
64位对象头

  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥,这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的Mark Word中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针

更直观的理解方式:
对象头总结
这也就意味着我们可以从Mark Word的后三位识别出该对象持有锁的状态.

三、锁状态

被synchronized关键字修饰的对象会存在以下几个状态: 无锁, 偏向锁, 轻量级锁, 重量级锁.

无锁

无锁就是该对象没有加任何锁, 所有线程可以任意执行修改该对象, 处于无锁状态的对象Mark Word最后三位为001
无锁

我们进行创建一个object进行测试

public static void main(String[] args) {
        System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
    }

结果:
无锁
由此可以证明一个对象在没有任何加锁操作的情况下为无锁状态

偏向锁

偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。

在这里插入图片描述

偏向锁延迟偏向

偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。

验证:

@Slf4j
public class LockEscalationDemo{

    public static void main(String[] args) throws InterruptedException {
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
        Thread.sleep(4000);
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
    }
}

4s后偏向锁为可偏向或者匿名偏向状态:
延迟偏向

偏向匿名状态

偏向锁在一开始创建时是不会偏向任何线程的, 如上图锁状态虽然是101, 但是后续线程id却是不存在. 只有当有线程访问持有锁时, Mark Word中的线程id才会偏向为持有线程. 我们把偏向锁等待偏向的过程叫做偏向匿名状态

匿名偏向状态结束:
在这里插入图片描述

偏向锁撤销

在通常只有一个线程访问加锁代码时偏向锁的效率很高并且持有偏向锁的线程也不会主动释放锁, 但是当涉及到其他线程也尝试获取锁或者一些其他特殊情况的时候偏向锁就会开始撤销.

只有当程序执行到安全点时, 偏向锁才会开始撤销, 根据不同情况进行进行转变

偏向锁撤销为无锁

在这里插入图片描述
我们对无锁态和偏向锁进行分析, 我们发现在偏向锁状态下是没有存放对象hashCode的位置, 因此我们一旦对处于偏向锁的对象在未偏向的时候也就是在偏向匿名状态下调用hashCode()方法就会从偏向锁转换为无锁

在这里插入图片描述

偏向锁撤销为轻量级锁

当线程发生轻微竞争或者对象调用notify()方法时, 偏向锁会撤销并升级为轻量级锁.

测试:

@Slf4j
public class LockEscalationDemo {

    public static void main(String[] args) throws InterruptedException {
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
        //HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
        Thread.sleep(4000);
        Object obj = new Object();
        // 思考: 如果对象调用了hashCode,还会开启偏向锁模式吗
        //obj.hashCode();
        //log.debug(ClassLayout.parseInstance(obj).toPrintable());

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName() + "开始执行。。。n"
                        + ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj) {
                    // 思考:偏向锁执行过程中,调用hashcode会发生什么?
                    //obj.hashCode();
                    log.debug(Thread.currentThread().getName() + "获取锁执行中。。。n"
                            + ClassLayout.parseInstance(obj).toPrintable());

                }
                log.debug(Thread.currentThread().getName() + "释放锁。。。n"
                        + ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "thread1");
        thread1.start();
        
        //控制线程竞争时机
        Thread.sleep(1);

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){
                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread2");
        thread2.start();

        Thread.sleep(5000);
        log.debug(ClassLayout.parseInstance(obj).toPrintable());

    }

在这里插入图片描述

在这里插入图片描述

偏向锁撤销为重量级锁

当我们在偏向锁已经有偏向线程时再次调用hashCode()或wait()方法时就不会进入无锁状态, 而是会直接撤销升级为重量级锁, 这是因为hashCode会保存在重量级锁的monitor对象中, 而wait()方法也是涉及到用户态和内核态的转换, 这些操作都是有monitor来进行的
在这里插入图片描述

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。

需要注意的是, 一旦进入了轻量级锁之后就不能撤回到偏向状态了, 只能等待所释放回到无锁状态或者通过锁膨胀升级成重量级锁

轻量级锁解锁:
在这里插入图片描述
轻量级锁升级成重量级锁:
在这里插入图片描述

重量级锁

重量级锁中的很多操作都会涉及到用户态与内核态之间的转换, 所以在重量级锁中的很多操作开销都非常的大, 例如wait()/notify()/notifyAll(), 而这些操作都是由monitor来进行的

Monitor

重量级锁基于Monitor机制实现, 依赖底层操作系统的互斥原语Mutex(互斥量),性能较低. 在HotSpot中, monitor是由ObjectMonitor来实现的, 这是内部基于 C++ 实现的一套机制。

monitor同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
在这里插入图片描述
在这里插入图片描述

ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):

ObjectMonitor() {
    _header       = NULL; //对象头  markOop
    _count        = 0;  
    _waiters      = 0,   
    _recursions   = 0;   // 锁的重入次数 
    _object       = NULL;  //存储锁对象
    _owner        = NULL;  // 标识拥有该monitor的线程(当前获取锁的线程) 
    _WaitSet      = NULL;  // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;    
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
    FreeNext      = NULL ;
    _EntryList    = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;

在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。
在这里插入图片描述


总结

最后上一个关系图帮助大家理解:
在这里插入图片描述

这就是这次synchronized关键字的内容了, 感谢你看到这里, 如果这篇文章能帮到你的话就麻烦点个赞呗, 谢谢!

最后

以上就是曾经玉米为你收集整理的详解并发编程之synchronized关键字, 学到就是赚到前言一、synchronized的使用二、对象内存布局与锁状态存储三、锁状态总结的全部内容,希望文章能够帮你解决详解并发编程之synchronized关键字, 学到就是赚到前言一、synchronized的使用二、对象内存布局与锁状态存储三、锁状态总结所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(46)

评论列表共有 0 条评论

立即
投稿
返回
顶部