深入理解 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() {
    // 该方法的锁作用范围是当前对象实例
}

静态方法级别的 synchronized

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

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

对象锁

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

class Example {
    public synchronized void instanceMethod() {
        // 该方法的锁作用范围是当前对象实例
    }
}

Example obj1 = new Example();
Example obj2 = new Example();

// 不同对象实例可以并发执行
obj1.instanceMethod();
obj2.instanceMethod();

类锁

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

class Example {
    public static synchronized void staticMethod() {
        // 该方法的锁作用范围是当前类的 Class 对象
    }
}

Example obj1 = new Example();
Example obj2 = new Example();

// 不论有多少实例对象,同一个类的静态方法只能被一个线程执行
Example.staticMethod();

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());
    }
}

在这个示例中,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提供更多高级功能。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值