并发编程-Synchronized介绍(结合源码和hostpot说明)

目录

一、Synchronized 概述

二、Synchronized在并发编程中解决的问题

2.1 解决原子性问题

2.1.1 问题代码

2.1.2 执行结果

2.1.3 优化代码

2.1.4 测试结果

2.1.5 优化代码分析

2.1.5.1 编译java源文件程序

2.1.5.2 查看编译文件

2.1.5.3 分析编译文件

2.2 解决可见性问题

2.2.1 问题代码

2.2.2 执行结果

2.2.3 优化代码

2.2.4 测试结果

2.2.5 优化代码分析

2.2.5.1 synchronized 修饰方法

2.2.5.1.1 源代码

2.2.5.1.2 执行结果

2.2.5.1.3 编译分析

2.2.5.2 synchronized 修饰代码块

2.2.5.2.1 代码

2.2.5.2.2 执行结果

2.2.5.2.3 编译分析

三、synchronized 底层原理

3.1 monitor 监视器

3.1.1 monitor 来源

3.1.2 对象头

3.1.2.1 对象头的内存布局

3.1.2.2 对象头底层hotspot内存结构

3.1.2.2.1 instanceOopDesc

3.1.2.3 对象头底层hotspot数据结构

3.1.2.3.1 Mark word

3.1.2.3.2 klass pointer

3.1.2.3.3 实例数据

3.1.2.3.4 对齐填充

3.1.2.3.5 ObjectMonitor 数据结构

3.1.3 monitor 对象锁原理

3.1.3.1 执行流程图

3.1.3.2 执行流程说明

3.1.4 总结

四、Synchronized优化

4.1 锁粗化

4.1.1 定义

4.1.2 代码示意

4.2 锁消除

4.2.1 定义

4.2.2 示意代码

4.3 锁升级(锁膨胀)

4.3.1 定义

4.3.2 锁升级过程


一、Synchronized 概述

synchronized 是Java 1.5之后引进的一个解决并发编程中原子性、可见性这两个并发特性问题的解决方案,它是Java中基于对象锁实现的并发编程同步关键字,今天我们就结合代码一起看下它是如何解决原子性、可见性问题的,以及它底层的实现原理是什么,同时看下Java 1.6之后,对它的优化措施是什么。

二、Synchronized在并发编程中解决的问题

2.1 解决原子性问题

2.1.1 问题代码

package com.ningzhaosheng.thread.concurrency.features.atom;

/**
 * @author ningzhaosheng
 * @date 2024/2/5 18:33:27
 * @description 原子性测试
 */
public class TestAtom {
    private static int count;
    // ++操作自增
    public static void increment(){
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}


2.1.2 执行结果

可见,以上执行结果在多线程环境下,多线程操作共享数据时,预期的结果,与最终执行的结果不符。

通过分析可知,其实count++操作,并不是一个原子性操作,它包含了getstatic、iconst_1、iadd、putstatic四个操作步骤,在多线程执行的过程中,会出现并发问题。

2.1.3 优化代码

package com.ningzhaosheng.thread.concurrency.features.atom.syn;

/**
 * @author ningzhaosheng
 * @date 2024/2/6 19:14:17
 * @description 测试synchronized保证原子性
 */
public class TestSynchronized {
    private static int count;

    // ++操作自增
    public static void increment() {
        synchronized (TestSynchronized.class) {
            count++;
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}


2.1.4 测试结果

我们发现,使用synchronized关键字后,在多线程并发的情况下,执行结果和预期值一致,没有了并发问题。

2.1.5 优化代码分析

为什么使用的synchronized之后,能解决由于原子性问题导致的并发问题呢?要回答这个问题,我们还需要编译代码,看下字节码,看到底synchronized做了些什么操作。

2.1.5.1 编译java源文件程序

2.1.5.2 查看编译文件
javap -v .\TestSynchronized.class

2.1.5.3 分析编译文件

我们可以通过分析编译出来的.class字节码文件,分析添加了synchronized关键字后,做了些什么操作。

通过上图中的字节码我们可以看到,添加synchronized关键字后,在执行count++操作的getstatic、iconst_1、iadd、putstatic等四个操作步骤指令的前后位置分别添加了monitorenter、monitorexit两个线程同步指令。monitorenter指令能使线程获得对象监视器(其实就是对象锁)。monitorexit指令释放并退出对象监视器(其实就是对象锁)。这两个指令的使用能避免多线程同时操作临街资源,并保证同一时间点,只会有一个线程正在操作临界资源。从而避免了并发安全问题。

2.2 解决可见性问题

2.2.1 问题代码

package com.ningzhaosheng.thread.concurrency.features.visible;

/**
 * @author ningzhaosheng
 * @date 2024/2/5 19:36:39
 * @description 测试可见性
 */
public class TestVisible {
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (flag) {
                // ....
            }
            System.out.println("t1线程结束");
        });

        t1.start();
        Thread.sleep(10);
        flag = false;
        System.out.println("主线程将flag改为false");
    }
}


2.2.2 执行结果

由结果可知,主线程修改了flag = false;但是并没有使t1线程里面的循环结束.

2.2.3 优化代码

package com.ningzhaosheng.thread.concurrency.features.visible.syn;

/**
 * @author ningzhaosheng
 * @date 2024/2/5 19:52:31
 * @description 测试synchronized
 */
public class TestSynchronized {
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (flag) {
                synchronized (TestSynchronized.class) {
                    //...
                }
                System.out.println(111);
            }
            System.out.println("t1线程结束");

        });

        t1.start();
        Thread.sleep(10);
        flag = false;
        System.out.println("主线程将flag改为false");
    }
}


2.2.4 测试结果

从测试结果可以看出,使用了synchronized同步代码块之后,在主线程中修改了flag=false 之后,线程t1也获取到最新的变量值,结束了while循环。也就是说synchronized也可以解决并发编程的可见性问题。那么synchronized是怎么保证并发编程的可见性的呢,我们接下来分析下。

2.2.5 优化代码分析

2.2.5.1 synchronized 修饰方法
2.2.5.1.1 源代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;

/**
 * @author ningzhaosheng
 * @date 2024/2/13 10:16:36
 * @description synchronized 修饰方法
 */
public class TestSynchronizedMethod {
    public static boolean flag = true;

    public static synchronized void runwhile() {
        while (flag) {
            System.out.println(111);
        }
        System.out.println("t1线程结束");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {

            runwhile();

        });
        t1.start();
        Thread.sleep(10);
        flag = false;
        System.out.println("主线程将flag改为false");
    }
}

2.2.5.1.2 执行结果

2.2.5.1.3 编译分析
javap -v .\TestSynchronizedMethod.class

可以看见,使用synchronized修饰方法后,通过javap -v 查看编译的字节码,会生成一个ACC_SYNCHRONIZED标识符,会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

可查看官网解析:

Chapter 2. The Structure of the Java Virtual Machine (oracle.com)

该标识符的作用是使当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的方法。

获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。

2.2.5.2 synchronized 修饰代码块
2.2.5.2.1 代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;

/**
 * @author ningzhaosheng
 * @date 2024/2/13 10:48:02
 * @description synchronized 修饰代码块
 */
public class TestSynchronizedCodeBlock {
    public static boolean flag = true;

    public static void runwhile() {
        while (flag) {
            synchronized (TestSynchronizedCodeBlock.class) {
                System.out.println(flag);
            }
            System.out.println(111);
        }
        System.out.println("t1线程结束");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {

            runwhile();

        });
        t1.start();
        Thread.sleep(10);
        flag = false;
        System.out.println("主线程将flag改为false");
    }
}

2.2.5.2.2 执行结果

2.2.5.2.3 编译分析
javap -v .\TestSynchronizedCodeBlock.class

可以看到,使用synchronized修饰代码块后,查看编译的字节码会发现再存取操作静态共享变量时,会插入monitorenter、monitorexit原语指令,关于这两个指令的说明,可查看文档:

Chapter 6. The Java Virtual Machine Instruction Set (oracle.com)

它实现可见性的原理:

当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的代码块。

获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。

三、synchronized 底层原理

从上面分析结果可以看出无论是synchronized代码块还是synchronized方法,其线程安全的语义实现最终依赖一个叫monitor的东西,那么这个神秘的东西是什么呢,我们一起来研究下。

3.1 monitor 监视器

3.1.1 monitor 来源

当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?是存在锁对象的对象头中的。

Monitor锁是基于操作系统的Mutex锁实现的,Mutex锁是操作系统级别的重量级锁,所以性能较低。

在Java中,创建的任何一个对象在JVM中都会关联一个Monitor对象,所以说任何一个对象都可以成为锁。

3.1.2 对象头

3.1.2.1 对象头的内存布局

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:

3.1.2.2 对象头底层hotspot内存结构

HotSpot采用instanceOopDesc和arrayOopDesc来描述对象头,arrayOopDesc对象用来描述数组类型。

3.1.2.2.1 instanceOopDesc

instanceOopDesc的定义的在Hotspot源码的 instanceOop.hpp 文件中,具体内容如下:

3.1.2.2.2 arrayOopDesc

arrayOopDesc的定义对应 arrayOop.hpp ,具体内容如下:

3.1.2.3 对象头底层hotspot数据结构

在普通实例对象中, oopDesc的定义包含两个成员,分别是 _mark 和 _metadata。

  • _mark 表示对象标记、属于markOop类型,也就是接下来要讲解的Mark World,它记录了对象和锁有关的信息。
  • _metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中Klass表示普通指针、 _compressed_klass 表示压缩类指针。

对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针,及对象指向它的类元数据的指针。

3.1.2.3.1 Mark word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。Mark Word对应的类型是 markOop 。源码位于 markOop.hpp 中。

jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/oops/markOop.hpp

在 64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

3.1.2.3.2 klass pointer

这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。 如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项 - XX:+UseCompressedOops 开启指针压缩,其中,oop即ordinaryobject pointer普通对象指针。开启该选项后,下列指针将压缩至32位:

  1. 每个Class的属性指针(即静态变量)
  2. 每个对象的属性指针(即对象变量)
  3. 普通对象数组的每个元素指针

当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。对象头 = Mark Word + 类型指针(未开启指针压缩的情况下)在32位系统中,Mark Word = 4 bytes,类型指针 =4bytes,对象头 = 8 bytes = 64 bits;在 64位系统中,Mark Word = 8 bytes,类型指针 = 8bytes,对象头 = 16 bytes = 128bits;

3.1.2.3.3 实例数据

就是类中定义的成员变量。

3.1.2.3.4 对齐填充

对齐填充并不是必然存在的,也没有什么特别的意义,他仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

3.1.2.3.5 ObjectMonitor 数据结构
class ObjectMonitor {
    // 对象头信息
    markOop _header;
    // 获取锁的次数
    int _count;
    // 等待队列
    Thread* _waiters;
    // 重入次数
    int _recursions;
    // 所属对象
    oop _object;
    // 当前拥有锁的线程
    Thread* _owner;
    // 等待队列
    WaitSet _WaitSet;
    // 入口队列
    EntryList _EntryList;
 
    // 加锁操作
    void lock();
    // 解锁操作
    void unlock();
    // 尝试加锁操作
    bool try_lock();
    // 等待操作
    void wait(jlong millis, bool interruptable);
    // 通知操作
    void notify(Thread* target_thread);
}

有几个重要的属性

  •  _WaitSet:是一个集合,当线程获到锁之后,但是还没有完成业务逻辑,也还没释放锁,这时候调用了Object类的wait()方法,这时候这个线程就会进入_WaitSet这个集合中等待被唤醒,也就是执行nitify()或者notifyAll()方法唤醒
  • _EntryList:是一个集合,当有多个线程来获取锁,这时候只有一个线程能成功拿到锁,剩下那些没有拿到锁的线程就会进入_EntryList集合中,等待下次抢锁
  •  _Owner:当一个线程获取到锁之后,就会将该值设置成当前线程,释放锁之后,这个值就会重新被设置成null
  •  _count:当一个线程获取到锁之后,_count的值就会+1,释放锁之后就会-1,只有当减到0之后,才算真正的释放掉锁了,其它线程才能来获取这把锁,synchornized可重入锁也是基于这个值来实现的。

hotspot 源码内容如下:

3.1.3 monitor 对象锁原理

synchronized 内部包括ContentionList、EntryList、WaitSet、在OnDeckOwner、!Owner这6个区域,每个区域的数据都代表锁的不同状态。

  • ContentionList:锁竞争队列,所有请求锁的线程都被放在竞争队列中。
  • EntryList:竞争候选列表,在ContentionList中有资格成为候选者来竞争锁资源的线程被移动到了 Entry List 中。
  • WaitSet:等待集合,调用wait方法后被阻塞的线程将被放在WaitSet中。
  • OnDeck:竞争候选者,在同一时刻最多只有一个线程在竞争锁资源,该线程的状态被称为 OnDeck。
  • Owner:竞争到锁资源的线程被称为Owner状态线程。
  • !Owner:在Owner线程释放锁后,会从Owner的状态变成!Owner。
3.1.3.1 执行流程图

3.1.3.2 执行流程说明
  • synchronized 在收到新的锁请求时首先自旋,如果通过自旋也没有获取锁资源,则将被放入锁竞争队列 ontentionList中。
  • 为了防止锁竞争时 ContentionList 尾部的元素被大量的并发线程进行 CAS访问而影响性能,Owner 线程会在释放锁资源时将ContentionList中的部分线程移动到EntryList中,并指定EntryList中的某个线程(一般是最先进入的线程)为OnDeck线程。Owner线程并没有直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,让OnDeck 线程重新竞争锁。在Java中把该行为称为“竞争切换”,该行为牺牲了公平性,但提高了性能。
  • 获取到锁资源的OnDeck线程会变为Owner线程,而未获取到锁资源的线程仍然停留在 EntryList中。
  • Owner线程在被wait方法阻塞后,会被转移到WaitSet队列中,直到某个时刻被notify 方法或者 notifyAll 方法唤醒,会再次进人 EntryList中。ContentionList、EntryList、WaitSet中的线程均为阻塞状态,该阻塞是由操作系统来完成的(在Linux内核下是采用pthread mutexlock内核所数实现的)。

3.1.4 总结

在synchronized中,在线程进人ContentionList 之前,等待的线程会先尝试以自旋的方式获取锁,如果获取不到就进人ContentionList,该做法对于已经进入队列的线程是不公平的,因此synchronized是非公平锁。另外,自旋获取锁的线程也可以直接抢占OnDeck 线程的锁资源。

synchronized是一个重量级操作,需要调用操作系统的相关接口,性能较低,给线程加锁的时间有可能超过获取锁后具体逻辑代码的操作时间。

JDK 1.6对synchronized做了很多优化,引人了适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等以提高锁的效率。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫作锁膨胀。在JDK1.6中默认开启了偏向锁和轻量级锁,可以通过XX:UseBiasedLocking 禁用偏向锁。

四、Synchronized优化

在JDK1.5的时候,Doug Lee推出了ReentrantLock,lock的性能远高于synchronized,所以JDK团队就在JDK1.6中,对synchronized做了大量的优化。

4.1 锁粗化

4.1.1 定义

如果在一个循环中,频繁的获取和释放做资源,这样带来的消耗很大,锁粗化就是将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗。

4.1.2 代码示意

package com.ningzhaosheng.thread.concurrency.features.atom.syn;

/**
 * @author ningzhaosheng
 * @date 2024/3/10 17:34:21
 * @description 测试锁粗化
 */
public class TestSynchronizedExpansion {
    // 正常代码
    public static void increment1() {
        StringBuffer sb = new StringBuffer();
        for(int i = 0;i< 100;i++){
            synchronized (TestSynchronizedExpansion.class){
                sb.append(i+"aa");
            }
        }
        System.out.println(sb.toString());
    }

    /**
     * 锁粗化示意代码
     */
    public static void increment2() {
        StringBuffer sb = new StringBuffer();
        synchronized (TestSynchronizedExpansion.class){
        for(int i = 0;i< 100;i++){
                sb.append(i+"aa");
            }
        }
        System.out.println(sb.toString());
    }

    public static void main(String[] args) {
         increment1();
    }

}

4.2 锁消除

4.2.1 定义

在synchronized修饰的代码中,如果不存在操作临界资源的情况,会触发锁消除,你即便写了synchronized,他也不会触发。

锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

4.2.2 示意代码

package com.ningzhaosheng.thread.concurrency.features.atom.syn;

/**
 * @author ningzhaosheng
 * @date 2024/3/10 17:46:31
 * @description 测试锁消除
 */
public class TestSynchronizedRemove {
    /**
     * 这个方法并没有存在共享资源,即使加了synchronized,也不会触发同步
     */
    public static synchronized void increment() {
        System.out.println("aaaaaaa");
    }
    public static void main(String[] args) {
        increment();
    }
}

4.3 锁升级(锁膨胀)

4.3.1 定义

锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫作锁膨胀。

4.3.2 锁升级过程

  • 无锁、匿名偏向:当前对象没有作为锁存在。
  • 偏向锁:如果当前锁资源,只有一个线程在频繁的获取和释放,那么这个线程过来,只需要判断,当前指向的线程是否是当前线程 。

      如果是,直接拿着锁资源走。

      如果当前线程不是我,基于CAS的方式,尝试将偏向锁指向当前线程。如果获取不到,触发锁升级,升级为轻量级锁。(偏向锁状态出现了锁竞争的情况)

  • 轻量级锁:会采用自旋锁的方式去频繁的以CAS的形式获取锁资源(采用的是自适应自旋锁

     如果成功获取到,拿着锁资源走
     如果自旋了一定次数,没拿到锁资源,锁升级。

  • 重量级锁:就是最传统的synchronized方式,拿不到锁资源,就挂起当前线程。(涉及用户态和内核态的切换)

好了,本次内容就分享到这,欢迎关注本博主。如果有帮助到大家,欢迎大家点赞+关注+收藏,有疑问也欢迎大家评论留言!

  • 19
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值