聊聊并发编程——多线程之synchronized

目录

一.多线程下数据不一致问题

二.锁和synchronized

2.1 并发编程三大特性

2.2引入锁概念

三.synchronized的锁实现原理

3.1 monitorenter和monitorexit

3.2synchronized 锁的升级

3.2.1偏向锁的获取和撤销

3.2.2轻量级锁的加锁和解锁

自适应自旋锁

轻量级锁的解锁

3.2.3重量级锁—线程阻塞

3.2.4锁的优缺点对比

3.3CAS实现原子性


一.多线程下数据不一致问题

我们知道,在并发编程中,多个线程同时访问共享资源时可能导致数据不一致、死锁、性能问题等严重后果。

像下面这个例子,i的值最后是多少呢?

聊聊并发编程——多线程之synchronized_第1张图片

简单的demo实验下:

public class Demo {
    private static int count = 0;
    public static void inc() {
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
    }
​
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> Demo.inc()).start();
        }
        Thread.sleep(3000);
        System.out.println("运行结果" + count);
    }
}

多次的运行结果是不相同的,并不是预期的100:

聊聊并发编程——多线程之synchronized_第2张图片

针对数据不一致的问题,我们先了解下java内存模型(Java Memory Model, JMM),它是一种抽象的模型,被定义出来屏蔽各种硬件和操作系统的内存访问差异。JMM定义了线程与主内存之间的抽象关系:线程之间的共享变量存储在主内存中,而每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

聊聊并发编程——多线程之synchronized_第3张图片

结合JMM不难理解,线程t1和t2通过操作本地内存中的副本进行刷新存储在主内存上的共享变量对象,但它们都没有在进行修改操作后立即告知其它线程。针对多线程并发访问共享资源导致数据不一致的问题,java给出了类似Synchronized和volatile的解决方案。

二.锁和synchronized

2.1 并发编程三大特性

并发编程中有三大核心特性,分别是原子性(Atomicity)、可见性(Visibility)、有序性(Ordering),通常被简称为 AVO 特性。这些特性对于多线程编程非常重要,因为它们确保了多线程环境下的正确性和可靠性。

  1. 原子性(Atomicity):

    • 原子性指的是一个操作是不可分割的单元,要么全部执行成功,要么全部不执行,不会被中断。原子操作可以看作是线程安全的,多个线程可以同时执行这个操作,而不会破坏操作的完整性。

    • 原子性保证了操作的完整性,避免了竞态条件,通常通过锁、原子类、事务等机制来实现。

  2. 可见性(Visibility):

    • 可见性指的是一个线程对共享变量的修改能够被其他线程立即感知到,即修改后的值在主内存中对其他线程是可见的。在多线程环境下,如果不采取适当的同步措施,共享变量的修改可能对其他线程不可见,导致意外的行为和错误。

    • 可以使用volatile关键字来确保变量的可见性,或者使用锁机制(如synchronized关键字)来同步访问共享变量。

  3. 有序性(Ordering):

    • 有序性指的是程序的执行顺序与代码的编写顺序一致,即代码按照预期的顺序执行,不会出现乱序或重排序的情况。

    • 在现代计算机架构中,为了提高性能,编译器和处理器可能会对指令进行重排序,但这种重排序在单线程环境下不会引发问题。然而,在多线程环境下,如果不正确地控制重排序,可能会导致不一致的结果。

    • 可以使用同步机制来确保有序性,例如在进入和退出锁的范围内,编译器和处理器会执行必要的指令重排序来维护有序性。

2.2引入锁概念

在可见性的理解上,java引入了锁的概念,用于防止多个线程同时访问或修改共享资源,以确保线程安全性。

聊聊并发编程——多线程之synchronized_第4张图片

而加锁的方式是使用synchronized关键字。

public class Demo {
    private static int count = 0;
    // 使用Synchronized加锁
    public static synchronized void inc() {
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
    }
​
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> Demo.inc()).start();
        }
        Thread.sleep(3000);
        System.out.println("运行结果" + count);
    }
}

聊聊并发编程——多线程之synchronized_第5张图片

synchronized 关键字在 Java 中主要用于实现互斥锁,它主要保证了并发编程中的原子性和可见性这两个特性。

  1. 原子性(Atomicity):

    synchronized 保证了被 synchronized 修饰的代码块或方法在同一时间只能被一个线程执行,这确保了其中的操作是原子的,即不会被中断或同时被其他线程访问。这有助于防止竞态条件和确保操作的完整性。

  2. 可见性(Visibility):

    当一个线程进入 synchronized 块或方法时,它会获取锁,并在释放锁时将修改的数据从线程的工作内存同步到主内存,这就确保了其他线程可以立即看到最新的数据。这满足了可见性要求,确保了共享数据的变更对其他线程是可见的。

synchronized 关键字在 Java 中有三种主要的用法,用于实现不同类型的同步:

  1. 实例方法同步:

    使用 synchronized 关键字修饰普通的实例方法,这会将锁定范围限制在该方法所属对象实例上,即同一对象的不同线程在同时访问这个方法时会互斥执行。这种方式适用于对实例变量的同步访问。

    public synchronized void instanceMethod() {
        // 这里的代码是线程安全的
    }
  2. 静态方法同步:

    使用 synchronized 关键字修饰静态方法,这会将锁定范围限制在类的 Class 对象上,即同一类的不同对象的不同线程在同时访问这个静态方法时会互斥执行。这种方式适用于对静态变量的同步访问。

    public static synchronized void staticMethod() {
        // 这里的代码是线程安全的
    }
  3. 同步代码块:

    使用 synchronized 关键字修饰代码块,可以精确地指定需要同步的对象。这允许你在方法内的特定部分实现同步,而不是整个方法。你需要指定一个对象作为锁,当多个线程尝试进入同步代码块时,它们必须获取相同的锁才能执行。

    public void someMethod() {
        // 一些非同步代码
        
        synchronized (lockObject) {
            // 这里的代码是线程安全的,锁定的是 lockObject 对象
        }
        
        // 更多非同步代码
    }

    设计模式中

    [单例模式的双重检查锁定]  https://blog.csdn.net/Elaine2391/article/details/132675080 

    其实就是一个简单的Synchronized和volatile的应用。

三.synchronized的锁实现原理

3.1 monitorenter和monitorexit

我们使用synchronized的时候没有手动的lock和unlock,那么synchronized是怎么加锁的呢?其实这里JVM帮我们处理了。反编译⼀段synchronized修饰代码块代码来看看:

找到上面我们的Demo类文件路径,javac Demo.java后生成class文件, javap -v Demo.class ,可以看到相应的字节码指令。synchronized修饰静态方法时,JVM采用monitorenter和monitorexit两个指令来实现同步,monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指向同步代码块的结束位置。

聊聊并发编程——多线程之synchronized_第6张图片

而且synchronized修饰同步方法时,JVM采用ACC_SYNCHRONIZED标记符实现同步,这个标识指明了该方法是一个同步方法。

聊聊并发编程——多线程之synchronized_第7张图片

monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。Monitor是什么呢?

所谓的Monitor其实是⼀种同步⼯具,也可以说是⼀种同步机制。在Java虚拟机(HotSpot)中,Monitor是由 ObjectMonitor实现的,可以叫做内部锁,或者Monitor锁。线程在获取锁的时候,实际上就是获得一个监视器对象(monitor),monitor可以认为是一个同步对象,所有的Java对象是天生携带monitor。多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系。

3.2synchronized 锁的升级

在JVM的自动内存管理中分析markword时,提到了偏向锁、轻量级锁、重量级锁和无锁状态。分析前我们先来思考一个问题:使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能提高性能又不能保证线程安全性。怎么办呢?

其实hotspot的作者经过调查发现,假设加锁的同步块分为下面三种情况,而大部分情况下,是处于第一种。

  1. 只有线程A进入临界区。(偏向锁)

  2. 线程A和线程B交替进入临界区。(轻量级锁)

  3. 线程A、线程B、线程C同时进入临界区。(重量级锁)

而这也是JDK1.6之后synchronized做出的优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。所以synchronized中,锁存在四种状态,分别是无锁、偏向锁、轻量级锁、重量级锁;锁的状态根据竞争激烈的程度从低到高不断升级。

针对共享资源对象加锁的操作,其实真正操作的是对象头中的markword。

img

3.2.1偏向锁的获取和撤销
  • 首先获取锁对象的Markword,判断是否处于可偏向状态。(偏向锁位biased_lock=0且ThreadId为空,表示未偏向任何线程)

  • 如果是可偏向状态,则通过CAS操作,把当前线程的ID写入到Markword

    a)如果CAS成功,那么就表示线程获取了锁对象的偏向锁,锁状态从无锁升级为偏向锁。

    b)如果CAS失败,说明其他线程已获得了偏向锁。这种情况说明当前存在锁竞争,需要暂停已获得偏向锁的线程,撤销偏向锁,升级为轻量级锁(这个操作在全局安全点执行,就是没有线程在执行字节码的时候)

  • 如果是已偏向状态,需要检查Markword中存储的ThreadId是否等于当前线程的ThreadId

    a)如果相等,不需要再次获得锁,可直接执行同步代码块。这就避免了锁的竞争和线程上下文切换。

    b)如果不相等,说明当前锁偏向于其他线程,需要撤销锁并升级为轻量级锁

对于原持有偏向锁的线程进行撤销时,原获得偏向的线程有两种情况:

a)原获得偏向锁的线程已经退出了临界区,就是执行完了代码块,这时候就会把对象头设置为无锁状态,并且争抢锁的进程可以基于CAS重新设置对象头偏向当前线程。

b)原获得偏向锁的线程还在执行同步块,这个时候就会将原获得偏向锁的线程的偏向锁升级为轻量级锁。

但是根据我们实际情况,绝大部分时候一定会存在2个以上的线程竞争,那么开启偏向锁反而提升了获取锁的资源消耗,可以通过jvm参数UserBiasedLocking来设置开启或关闭偏向锁。流程图分析如下:

聊聊并发编程——多线程之synchronized_第8张图片

3.2.2轻量级锁的加锁和解锁
  • 首先,jvm会判断是否是重量级锁,如果不是,会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象Markword复制到该锁记录中。

聊聊并发编程——多线程之synchronized_第9张图片

  • 复制成功后,jvm使用CAS操作将对象头Markword更新为指向锁记录的指针,并且将锁记录里的own指针指向对象头的Markword。

聊聊并发编程——多线程之synchronized_第10张图片

  • 更新对象头Markword成功的情况,当前线程持有对象锁,并且对象Markword锁标志设为‘00’,即表示此对象处于轻量级锁状态。

  • 更新对象头Markword失败的情况,jvm先检查对象MarkWord是否指向当前线程栈帧中的锁记录,如果是就表示锁重入。如果不是就表示锁对象被其他线程抢占,进行自旋等待(默认10次),等待次数达到阈值仍未获取到锁,膨胀为重量级锁。

自适应自旋锁

自旋锁就是锁在原地循环执行一个啥都没有的for循环操作,是会消耗cpu的。JDK1.6后引入了自适应自旋锁,自适应意味着着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自

旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。

如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

轻量级锁的解锁

就是获得锁的逆向逻辑,通过CAS操作把栈帧中的LockRecord替换回锁对象的Markword中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀为重量级锁。流程图如下:

聊聊并发编程——多线程之synchronized_第11张图片

3.2.3重量级锁—线程阻塞

轻量级锁膨胀到重量级锁后,线程只能被挂起阻塞来等待被唤醒了。阻塞是重量级锁的标志。重量级锁加锁的基本流程:

聊聊并发编程——多线程之synchronized_第12张图片

举例说明下:

定义线程A传入锁对象lock,包含一个简单的wait操作。

public class ThreadA extends Thread{
    private Object lock;
    public  ThreadA(Object lock){
        this.lock = lock;
    }
    @Override
    public void run() {
        synchronized (lock) {
            System.out.println("ThreadA start");
            try {
                lock.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("ThreadA end");
        }
    }
}

定义线程B传入锁对象,包含一个notify操作。

public class ThreadB extends Thread{
    private Object lock;
    public ThreadB(Object lock){
        this.lock = lock;
    }
    @Override
    public void run() {
        synchronized (lock) {
            System.out.println("ThreadB start");
            lock.notify();
            System.out.println("ThreadB end");
        }
    }
}

创建A线程启动,B线程启动,执行结果:

public class WaitNotify {
    public static void main(String[] args) {
        Object lock = new Object();
        ThreadA threadA = new ThreadA(lock);
        threadA.start();
​
        ThreadB threadB = new ThreadB(lock);
        threadB.start();
    }
}

聊聊并发编程——多线程之synchronized_第13张图片

3.2.4锁的优缺点对比

聊聊并发编程——多线程之synchronized_第14张图片

3.3CAS实现原子性
  • CAS叫做CompareAndSwap,⽐较并交换,主要是通过处理器的指令来保证操作的原⼦性的。

  • CAS 指令包含 3 个参数:共享变量的内存地址 A、预期的值 B 和共享变量的新值 C。

  • 只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C。作为⼀条 CPU 指令,CAS 指令 本身是能够保证原⼦性的

CAS操作三大问题

  • ABA问题,在比较期间,发生了A-B-A,CAS感知不到过程,解决方案:加版本号。1A-2B-3A.

  • 循环时间长开销大

  • 只能保证一个共享变量的原子操作

你可能感兴趣的:(并发编程,java,jvm,算法)