• 深入理解 Java 中的 synchronized 关键字


    引入多线程的重要性和挑战

    可以参考另一篇文章 https://blog.csdn.net/qq_41956309/article/details/133717408

    JMM(Java Memory Model,Java 内存模型)

    什么是JMM

    JMM(Java Memory Model,Java 内存模型)是一种规范,用于定义多线程程序中的内存访问规则和语义,确保多线程程序的正确性和可移植性。JMM 定义了线程如何与主内存和工作内存交互,以及如何确保多线程程序中的内存可见性和一致性。

    引入JMM的目的

    引入 Java Memory Model(JMM)的主要目的是为了解决多线程编程中的内存可见性和一致性问题,以及确保多线程程序的正确性和可移植性 它提供了规则和机制,使得多线程编程更容易管理和理解,减少了开发人员因多线程编程而引入的错误和 bug。

    引入JMM的原因

    在Java语言之前,C、C++等语言是直接使用物理硬件和操作系统的内存模型的,正因为这些语言直接和底层打交道,使得这些语言执行效率更高,但同时也带来了一些问题:由于不同平台上,软件和硬件都有一定差异(比如硬件厂商不同、操作系统不同),导致有可能同一个程序在一套平台上执行没问题,另一个平台上执行却得到不一样的结果,甚至报错。
    Java语言试图定义一个Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,达到让Java程序在不同平台上都能达到一致的内存访问效果的目的,这就是Java内存模型的意义。

    JMM的主要结构和概念

    1.主内存(Main Memory): 主内存是所有线程共享的内存区域,用于存储共享数据,包括全局变量、对象实例数据和类信息。主内存是多线程程序中的主要数据存储区域。

    2.工作内存(Working Memory): 每个线程都有自己的工作内存,用于存储线程私有的数据,包括局部变量、方法参数和方法调用信息。工作内存用于缓存主内存中的数据。

    3.主内存与工作内存交互规则:要想读主内存的变量,首先得将主内存中的变量读取到工作内存中;要想修改主内存的变量,首先得先修改工作内存中的变量副本,然后再将变量副本写到主内存中。

    4.同步操作: JMM 提供了一些同步操作,如锁、synchronized 块和 volatile 关键字,用于协调线程之间的数据访问。这些同步操作确保了线程之间的协同和互斥访问。

    5.Happens-Before 关系: JMM 引入了 Happens-Before 关系,定义了事件发生的顺序和相关性。如果事件 A Happens-Before 事件 B,那么事件 B 将看到事件 A 之前的所有操作。这有助于确保多线程程序中的正确性。

    i++为什么线程不安全

    假设我们有一个共享变量 int i = 0;,两个线程 A 和 B 同时执行 i++ 操作。
    1.线程 A 和线程 B 同时启动,各自创建自己的工作内存,其中 i 的初始值为 0
    请添加图片描述
    2.线程A从主内存中读取i=0 然后执行i++操作此时 i=1 并且将i的值写回主内存中
    请添加图片描述
    3.线程B因为是和线程A同时执行 所以在线程A将i进行++ 导致i=1并写会主内存之前就从主内存中读到了i=0 因此读到线程B自己工作内存中的 i 的值依然 = 0 并对0 进行++ 此时线程B自己工作内存中i =1 并把i=1写回主内存中 覆盖掉了原先A线程写到主存中的i=1 此时主存中的i =1 而不是 =2 所以导致了线程不安全的问题
    请添加图片描述

    如何解决并发导致的线程不安全问题

    假设我们增加一个共享变量 lock 当线程A抢占到这个lock的时候才能继续执行,线程B没抢占的时候加入一个阻塞队列中等待只有等线程释放lock之后裁让继续执行 那么此时等线程A对i进行++之后 主内存中的i=1 然后释放lock 此时B能拿到lock在去读主内存中 i的值此时i已经=1了 那么将1 加载进自己的工作内存在进行++ 此时就能得到正确的值并且写回到主内存中
    在这里插入图片描述

    synchronized 的基本概念:

    synchronized 是 Java 中用于实现线程同步的关键字。它的主要作用是确保多个线程能够安全地访问共享资源,避免竞态条件和数据不一致性的问题

    特性

    1.互斥性(Mutual Exclusion): synchronized 确保在同一时间只有一个线程可以获得锁并执行被 synchronized 修饰的代码块或方法。这防止多个线程同时访问共享资源,从而避免竞态条件和数据不一致性。

    2.对象级别锁: synchronized 可以用于锁定对象或类。当它用于实例方法时,它锁定了对象实例;当它用于静态方法时,它锁定了类。这意味着不同的对象实例可以同时调用不同的实例方法,而不会互相阻塞。

    3.重入性(Reentrancy): Java 中的 synchronized 支持重入性,即一个线程可以多次获得同一个锁而不会被阻塞。这允许线程在持有锁的情况下进入另一个 synchronized 块或方法。重入性有助于避免死锁。

    4.内置锁(Intrinsic Lock): synchronized 使用内置锁,也称为监视器锁。每个 Java 对象都有一个关联的内置锁,线程可以通过 synchronized 来获取或释放这个锁。当一个线程获取锁时,其他线程会被阻塞,直到锁被释放。

    5.阻塞性质: 当一个线程无法获得 synchronized 块或方法的锁时,它会被阻塞,直到锁可用。这确保了线程的排队执行,避免了竞争条件。

    6.性能开销: synchronized 在某些情况下可能引入性能开销,因为只有一个线程可以执行被锁定的代码块。在高并发情况下,这可能导致性能下降。因此,在某些情况下,更高级的同步工具,如 ReentrantLock,可以提供更好的性能控制

    synchronized 的作用范围:

    例方法级别的 synchronized

    当 synchronized 修饰实例方法时,锁的范围是该实例对象。这意味着不同实例对象之间的锁互不干扰,每个实例对象都有自己的锁,可以并发执行相同的方法,但同一个实例对象的方法只能被一个线程执行,其他线程会被阻塞。

    public synchronized void instanceMethod() {
        // 该方法的锁作用范围是当前对象实例
    }
    
    
    • 1
    • 2
    • 3
    • 4

    静态方法级别的 synchronized

    当 synchronized 修饰静态方法时,锁的范围是该类的 Class 对象,因此它是全局的,对该类的所有实例对象生效。这意味着无论多少实例对象存在,只有一个线程能够同时执行该静态方法。

    public static synchronized void staticMethod() {
        // 该方法的锁作用范围是当前类的 Class 对象
    }
    
    
    • 1
    • 2
    • 3
    • 4

    对象锁

    当 synchronized 修饰实例方法时,它使用的是对象锁,作用范围是该对象实例。多个线程可以同时访问不同对象实例的方法,但同一个对象实例的方法只能被一个线程执行

    class Example {
        public synchronized void instanceMethod() {
            // 该方法的锁作用范围是当前对象实例
        }
    }
    
    Example obj1 = new Example();
    Example obj2 = new Example();
    
    // 不同对象实例可以并发执行
    obj1.instanceMethod();
    obj2.instanceMethod();
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    类锁

    当 synchronized 修饰静态方法时,它使用的是类锁,作用范围是整个类,对该类的所有实例对象生效。只有一个线程能够同时执行该静态方法,无论有多少实例对象存在。

    class Example {
        public static synchronized void staticMethod() {
            // 该方法的锁作用范围是当前类的 Class 对象
        }
    }
    
    Example obj1 = new Example();
    Example obj2 = new Example();
    
    // 不论有多少实例对象,同一个类的静态方法只能被一个线程执行
    Example.staticMethod();
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    synchronized案例:

    演示如何使用 synchronized 实现多线程访问共享资源的同步。在这个例子中,我们有一个银行账户对象,多个线程同时进行存款和取款操作,需要确保线程安全。

    class BankAccount {
        private int balance = 1000;
    
        public synchronized void deposit(int amount) {
            balance += amount;
        }
    
        public synchronized void withdraw(int amount) {
            if (balance >= amount) {
                balance -= amount;
            } else {
                System.out.println("Insufficient funds");
            }
        }
    
        public synchronized int getBalance() {
            return balance;
        }
    }
    
    public class BankExample {
        public static void main(String[] args) {
            BankAccount account = new BankAccount();
    
            // 创建多个存款线程
            Thread depositThread1 = new Thread(() -> {
                for (int i = 0; i < 100; i++) {
                    account.deposit(10);
                }
            });
    
            Thread depositThread2 = new Thread(() -> {
                for (int i = 0; i < 100; i++) {
                    account.deposit(10);
                }
            });
    
            // 创建多个取款线程
            Thread withdrawThread1 = new Thread(() -> {
                for (int i = 0; i < 50; i++) {
                    account.withdraw(20);
                }
            });
    
            Thread withdrawThread2 = new Thread(() -> {
                for (int i = 0; i < 50; i++) {
                    account.withdraw(20);
                }
            });
    
            // 启动线程
            depositThread1.start();
            depositThread2.start();
            withdrawThread1.start();
            withdrawThread2.start();
    
            // 等待所有线程执行完毕
            try {
                depositThread1.join();
                depositThread2.join();
                withdrawThread1.join();
                withdrawThread2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            // 打印账户余额
            System.out.println("Final account balance: " + account.getBalance());
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71

    在这个示例中,BankAccount 类的存款和取款方法都使用 synchronized 修饰,以确保同一时间只有一个线程可以执行这些方法。这样,多个线程可以安全地访问共享的账户对象,避免了竞态条件和数据不一致性。

    synchronized 的原理:

    Java对象结构

    请添加图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    重量级锁存在的性能问题

    在Linux系统架构中可以分为用户空间和内核,我们的程序都运行在用户空间,进入用户运行状态就是所谓的用户态。在用户态可能会涉及到某些操作如I/O调用,就会进入内核中运行,此时进程就被称为内核运行态,简称内核态。

    内核: 本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
    用户空间: 上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。

    系统调用: 为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
    使用monitor是重量级锁的加锁方式。在objectMonitor.cpp中会涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,
    执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个时候就会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。试想,如果程序中存在大量的锁竞争,那么会引起程序频繁的在用户态和内核态进行切换,严重影响到程序的性能。这也是为什么说synchronized效率低的原因

    synchronized锁优化

    JDK1.6中引入偏向锁和轻量级锁对synchronized进行优化。此时的synchronized一共存在四个状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁着锁竞争激烈程度,锁的状态会出现一个升级的过程。即可以从偏向锁升级到轻量级锁,再升级到重量级锁。锁升级的过程是单向不可逆的,即一旦升级为重量级锁就不会再出现降级的情况。

    优化后锁的状态

    偏向锁

    什么是偏向锁

    偏向锁(Biased Locking)是Java中用于提高单线程访问同步块的性能的一种锁机制。它的核心思想是,当一个线程第一次访问一个同步块时,虚拟机会将锁对象标记为偏向锁,并记录获取锁的线程ID。之后,如果同一个线程再次访问该同步块,它可以直接获取锁,而无需竞争,从而提高了性能。

    为什么要引入偏向锁

    大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

    偏向锁的作用

    减少同一线程获取锁的代价。

    加锁过程

    1.首次加锁:当第一个线程尝试获取该对象的锁时,虚拟机检测到该对象没有被任何线程锁定,于是它将该对象的状态设置为偏向锁状态,并将偏向锁标志位设置为1。

    2.线程ID记录:虚拟机记录获取锁的线程的ID,并将其存储在对象头中的偏向线程ID字段。此时,对象已经被偏向于第一个线程。

    3.偏向锁校验: 当同一个线程再次尝试获取该对象的锁时,虚拟机会检查记录在对象头中的线程ID,与当前线程的ID进行比较。如果两者相同,说明当前线程已经获取了偏向锁,可以直接进入临界区执行,无需进一步竞争。

    4.偏向锁升级: 如果当前线程的ID与记录在对象头中的线程ID不匹配,表示其他线程曾经获取过这个锁,偏向锁不再生效,会被撤销。此时,虚拟机会尝试使用CAS(Compare and Swap)操作来竞争锁。

    5.获取锁: 如果CAS操作成功,当前线程获得了锁,进入临界区执行。如果CAS操作失败,虚拟机会尝试使用轻量级锁或重量级锁来竞争锁,具体取决于竞争情况。

    什么是偏向锁撤销

    偏向锁的撤销是为了应对多线程竞争的情况,以保证多线程环境下的锁操作能够正确执行。一旦偏向锁被撤销,锁对象会升级为轻量级锁或重量级锁,这些锁提供了更复杂的锁协议,以确保多线程环境下的正确性和公平性。

    偏向锁撤销流程

    竞争检测: 当有一个线程尝试获取一个对象的偏向锁时,虚拟机会进行竞争检测。它会检查对象头中的偏向锁标志位和线程ID字段,以确认是否有其他线程曾经获取过该锁。

    判断偏向锁状态: 如果虚拟机发现偏向锁标志位为1,而且线程ID字段不是当前线程的ID,那么说明有其他线程曾经获取过该锁。

    偏向锁撤销: 当偏向锁被撤销时,虚拟机会将对象头中的偏向锁标志位设置为0,表示不再使用偏向锁。线程ID字段也会被清空。

    锁升级: 偏向锁被撤销后,虚拟机会将对象的锁状态升级为轻量级锁或重量级锁,具体升级方式取决于当前的竞争情况。这通常涉及到CAS(Compare and Swap)操作,用于确保多线程环境下的正确性和公平性。

    轻量级锁

    加锁过程

    轻量级锁优化性能的依据是对于大部分的锁,在整个同步生命周期内都不存在竞争。 当升级为轻量级锁之后,MarkWord的结构也会随之变为轻量级锁结构。JVM会利用CAS尝试把对象原本的Mark Word 更新为Lock Record的指针,成功就说明加锁成功,改变锁标志位为00,然后执行相关同步操作。
    轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁就会失效,进而膨胀为重量级锁。

    解锁过程

    当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

    自旋锁

    轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
    自旋锁是基于在大多数情况下,线程持有锁的时间都不会太长。如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),不断的尝试获取锁。空循环一般不会执行太多次,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入同步代码。如果还不能获得锁,那就会将线程在操作系统层面挂起,即进入到重量级锁。

    重量级锁

    当线程的自旋次数过长依旧没获取到锁,为避免CPU无端耗费,锁由轻量级锁升级为重量级锁。获取锁的同时会阻塞其他正在竞争该锁的线程,依赖对象内部的监视器(monitor)实现,monitor又依赖操作系统底层,需要从用户态切换到内核态,成本非常高。

    成本高的原因

    当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

    偏向锁、轻量级锁、重量级锁的对比

    在这里插入图片描述

    synchronized锁升级过程

    在这里插入图片描述
    1.当线程A尝试获取该对象的锁时,虚拟机检测到该对象没有被任何线程锁定,于是它将该对象的状态设置为偏向锁状态,并将偏向锁标志位设置为1。并且在Mark Word中记录线程A的ID 此时,对象已经被偏向于线程A。
    假设当线程A再次尝试获取锁 虚拟机会检查对象的状态。由于对象已经被偏向于当前线程,偏向锁标志位和线程ID会与当前线程匹配。因此,虚拟机可以直接让线程获取锁,无需竞争直接执行同步代码

    2.当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步代码。如果抢锁失败。

    3.代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步代码。如果保存失败,表示抢锁失败,竞争太激烈。

    1. 轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步代码,如果失败则继续执行步骤5;

    2. 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

    synchronized 的局限性:

    1.粒度粗: synchronized 只能锁定代码块或方法,这意味着只能对整个方法或代码块进行同步。如果某个方法中只有一小部分需要同步,使用synchronized可能导致过多的线程阻塞。

    2.性能开销: synchronized 在某些情况下可能引入性能开销,因为只有一个线程可以执行被锁定的代码块,其他线程必须等待。这可能导致性能下降,尤其在高并发情况下。

    3.无法中断: 一旦线程获得synchronized锁,其他线程无法中断它,只能等待锁被释放。这可能导致线程在等待锁时无法响应中断信号。

    4.无法设置超时: synchronized 也无法设置获取锁的超时时间,这使得在某些情况下可能导致线程无限期等待。

    5.只支持互斥锁: synchronized 只提供了一种互斥锁,这意味着只有一个线程可以获取锁,其他线程必须等待。它不支持更复杂的同步模式,如读写锁或信号量。

    6.局部性差: synchronized 不提供足够的工具来优化缓存局部性,这可能会导致内存访问效率降低。

    与其他同步机制的对比

    synchronized vs. ReentrantLock

    synchronized 是内置锁,而 ReentrantLock 是Java提供的一个可重入锁。
    ReentrantLock提供了更多的灵活性,如超时等待、可中断锁、公平性等。
    synchronized 更简单易用,但ReentrantLock提供更多高级功能。

  • 相关阅读:
    MySQL 基础篇(第04话):mysqld 和 mysql 命令的区别
    Git Pull failure 【add/commit】
    重读Java设计模式: 适配器模式解析
    maven-依赖管理
    Docker使用nodejs镜像构建express服务
    为什么需要 Buffer Pool?
    《德阳市餐饮服务业油烟污染防治管理办法(征求意见稿)》之创新油烟监管
    终结处理和垃圾回收
    led灯什么牌子的质量好?Led护眼台灯排行榜
    CatFly【汇编代码还原】
  • 原文地址:https://blog.csdn.net/qq_41956309/article/details/133803614