原创

详解java多线程锁

温馨提示:
本文最后更新于 2023年03月24日,已超过 360 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

java多线程锁

多线程程序是并发编程的核心,而Java多线程锁则是保证线程安全的重要手段。但是,不同类型的锁适用于不同的场景,而正确地选择锁对于程序的性能和正确性至关重要。在本文中,我们将深入探讨Java多线程锁的工作原理和最佳实践。

多线程模型

Java的多线程模型是基于线程的抢占式调度机制,它允许多个线程同时执行,并且使用共享内存来实现线程间通信。

file

可以看出,java可以创建多个线程并行执行,读写的内存都是同一个进程虚拟内存的数据,就可能会导致出现问题:

public class Main {
    static int a = 1;

    public static void main(String[] args) throws Exception {
        Runnable r = () -> {
            for (int i = 0; i < 100000; i++) {
                //读取a的值
                a = a + 1;
            }
        };
        Thread t1 = new Thread(r);
        t1.start();
        Thread t2 = new Thread(r);
        t2.start();
        t1.join();
        t2.join();
        System.out.println(a);
    }
}

输出:
file

可以看出,在2个线程同时运行的情况下,a的值并不是完整的20万,而是小于20万,原因就是在运行的过程中,多线程出现了数据竞争和内存一致性的问题:

  • a的值为1
  • t1线程获取到a的值为1
  • t2线程获取到a的值为1
  • t1将值更新为2
  • t2将值更新为2
    ...
    由于t1和t2是同时运行的,所以就出现了获取到相同的值,更新又更新到了相同的值的情况

同样的例子还有很多,正是因为多线程下内存共享的问题,那么该如何解决这个问题呢?

java内存模型

首先我们需要简单了解一下java的内存模型结构:
file

什么是本地内存?

JMM(Java Memory Model) java内存模型,是在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型

在进程概念中,并没有本地内存的概念,只有进程内存,它是一个虚拟的概念.

在线程运行时,需要先把线程所需的内存数据提取到cpu cache中,然后压入寄存器进行cpu运算:

file

可以看出,在jMM内存模型中,除了进程的虚拟内存区域之外,额外存在一个本地内存的虚拟区域概念,这个内存只对线程本身可见,属于线程运行时所需要用到的高速缓存 (其实所有语言的进程都会有这个内存概念)

当然,JMM不仅仅是只有这一个概念,它还包括了java在编译优化代码时候的重排序,内存屏障等概念,这个暂时不讲.

顺序一致性

在上面的例子我们可以看到,a的值不正确的原因是2个线程同时读取,或者同时写入值,导致了结果不正确,那么,如果变量的读写都保证一个顺序,是不是就不会出现这个问题呢?
我们先假设a+=1这个命令只需要执行一次,而不是先获取a,再赋值a

file

在顺序一致性模型中,所有变量在同一时间被一个线程获取,其他线程需要等待,线程实现了按照顺序的串行执行,这样就使得了数据正确
但是,这样多线程的优势就没了,所有线程都是串行化执行,不能并发执行,同时这里面还有一个重排序的问题

在上面,我们有一个假设,那就是:假设a+=1 这个命令只需要执行一次,而不是先获取a,再复制a,2次操作,如果是2次操作,会发生什么问题呢?
file

可以看出,当分成2次操作的时候,其实产生了一个临时变量t,在获取a=1,时,存储了这个1值,然后再将1+1写入给了a
由于是分成了2步操作,在线程执行的时候,先后顺序可能是不一致的,就又会导致变量更新出错的问题,

所以,在多线程环境下,是无法保证顺序一致性的这个语义的

重排序

在上面的多线程顺序一致性例子中,我们知道了多线程情况下,如果获取+写入的不再同一个位置执行,就会出现与预期结果不符的问题

在单线程情况下,cpu,编译器为了提高并行度的情况下,也会对操作指令做出更改,例如:

        int a = 0;
        int b = 0;
        a = 1;
        b = a;
        a = b+1;
        b = a+1;
        a = b+1;
        b = a+1;

重排序下,可能会出现

        a = 1;
        a = b+1;
        a = b+1;
        b = a;
        b = a+1;
        b = a+1;

这个时候,代码和预期结果是不一样的,理论上是不允许出现的

编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性(上面的a和b语句互相依赖),编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

        int a = 0;
        int b = 0;
        a++;
        b++;
        a++;
        b++;
        a++;
        b++;

在这个例子中,需要循环的给a和b操作数据,cpu为了优化,则可能会优化成:

        int a = 0;
        int b = 0;
        a++;
        a++;
        a++;
        b++;
        b++;
        b++;

这个时候,执行顺序其实是已经变了,但是这个执行顺序并不会对程序的结果造成影响,这个叫做as-if-serial 语义

as-if-serial 语义只能确保单线程不会出现问题,如果是在多线程上,就算是没有遵守了没有互相依赖的重排序,也可能会导致改变程序的执行结果

public class Main {
    static int flag = 0;
    static int a = 0;
    public static void main(String[] args) throws Exception {
        Runnable r1 = () -> {
            flag = 1;
            a = 1;
        };
        Runnable r2 = () -> {
            if (flag == 1) {
                int c = a * a;
            }
        };
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r2);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

在这个例子中,t1线程中的flag和a并没有依赖关系,所以可能会重排序,t2的flag和c也没有依赖关系,所以可能会出现:
file

在多线程的情况下,虽然对没有依赖关系的语句进行了重排序,但是实际上已经改变了执行的结果

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

内存屏障

为了保证内存的可见性,java编译器会在生成指令的适当位置插入内存屏障来禁止特定类型的重排序,,JMM把内存屏障指令分为4类:

file

这个表如果不好理解,可以粗俗的理解为: Load (读取内存必须是读取最新的),Store(存储必须刷新到内存)
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处 理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂 贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

happens-before

在JMM中,提出了happens-before的概念用于实现as-if-serial语义,

happens-before 指定了2个操作之间的执行顺序,如果 a happen-before B,那么A的执行顺序必须排在B之前

这个原则就是:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化,怎么排序都行

注意,是单线程程序,和 正确同步的多线程程序,多线程需要正确同步.

线程同步

在多线程编程中,正确同步指的是在多个线程之间共享的数据和资源被正确地访问和更新,从而避免了竞态条件、死锁和其他的并发问题。这种同步是通过使用同步机制(如锁、信号量、条件变量等)和原子操作(如原子加、原子比较交换等)来实现的。

一个正确同步的多线程程序是指程序中的多个线程能够正确地共享数据和资源,而不会出现竞态条件、死锁等问题,并且程序能够正确地执行并达到预期的结果。在这种情况下,编译器和处理器的优化和排序不会影响程序的正确性和执行结果。

重点在于以下几点
1:共享变量的可见性,如果修改了变量,在其他线程能马上获取到最新的值,也就是线程本地内存不要缓存旧值
2:共享变量的线程安全性,多个线程访问同一个变量时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个变量的行为都可以获得正确的结果,那么这个变量就是线程安全的。
3:原子操作,如果对一个变量的操作是原子性的(不会出现先获取,再加值),就不会出现错误的结果
4:同步机制,如果多线程在同一时间只会有一个线程在操作变量,就不会出现线程共享问题

CAS

CAS的全称为Compare-And-Swap,直译就是对比交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值。经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。 简单解释:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。

CAS锁可以保证变量的原子操作

public class Main {
    static AtomicInteger a = new AtomicInteger(1);

    public static void main(String[] args) throws Exception {
        Runnable r = () -> {
            for (int i = 0; i < 100000; i++) {
                int oldValue, newValue;
                do {
                    //读取a的值
                    oldValue = a.get();
                    newValue = oldValue + 1;
                } while (!a.compareAndSet(oldValue, newValue));
            }
        };
        Thread t1 = new Thread(r);
        t1.start();
        Thread t2 = new Thread(r);
        t2.start();
        t1.join();
        t2.join();
        System.out.println(a.get());
    }
}

将int改为AtomicInteger类型,在每次+1时通过CAS方式+1,判断如果旧值不对,则循环更新,直到更新成功

可以看出,CAS的更新需要判断成功和失败,如果目的就是更新,那失败之后就还得重复的去尝试更新,也就是自旋.

CAS ABA问题

ABA问题是指在进行CAS操作时,如果一个变量的值先被修改为B,再被修改回A,此时CAS操作会认为该变量的值没有被修改过,从而可能导致出错。
解决这些问题的方式主要是使用版本号控制,即在每次修改变量时,都增加一个版本号,这样可以解决ABA问题。另外,为了避免循环时间长问题,可以设置一个尝试次数的上限,如果超过这个上限仍然没有成功,就放弃操作。

synchronized关键字

synchronized关键字用于实现线程同步,它可以用于方法或代码块上。

作为方法修饰符使用synchronized关键字时,它可以确保在同一时间内只有一个线程可以进入被修饰的方法,其他线程必须等待该方法执行完成后才能进入。这样就避免了多个线程同时访问共享资源时可能引发的数据竞争和并发问题。

作为代码块使用synchronized关键字时,它可以确保在同一时间内只有一个线程可以执行该代码块中的代码,其他线程必须等待当前线程执行完该代码块后才能执行。这样可以在并发环境下保证共享资源的安全访问。

public class Main {
    static  int a = 1;

    public static void main(String[] args) throws Exception {
        Runnable r = () -> {
            for (int i = 0; i < 100000; i++) {
                //读取a的值
                synchronized (Main.class){
                    a++;
                }
            }
        };
        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(a);
    }
}

通过synchronized关键字,可以使得一个代码块的代码每次只会有一个在运行,也就是实现了该代码块的顺序同步问题,所以不会出现问题

图示:
file

对于普通同步方法,锁是当前实例对象。
对于静态同步方法,锁是当前类的Class对象。
对于同步方法块,锁是Synchonized括号里配置的对象。

synchronized内存语义

在增加synchronized关键字后,JMM到底做了什么呢?
首先,在增加synchronized关键字后,

  • 执行的代码块将会先尝试获取锁,没有获得的话将阻塞等待
  • 获得锁的代码块开始读取主内存的变量数据,写入到本地内存
  • 执行代码,将更新后的变量写入到本地内存
  • 释放锁,将本地内存写入到主内存,并向阻塞等待锁的线程发消息
  • 等待锁的代码块开始获得锁,读取最新的内存开始执行
    ...

happens-before关系

synchronized的happens-before关系取决于谁先获取的锁,大致为

  • 获取锁的代码块 happens-before 代码块本身
  • 代码块本身 happens-before 释放锁
  • 释放锁 happens-before 等待获取锁的其他线程代码块
  • ....

可以看出,有synchronized关键字的代码块,将严格执行happens-before关系,先获得锁代码块A一定是happens-before代码块B
这样就使得了程序运行正确

synchronized的实现原理

synchronized用的锁存在于java的对象头里,根据具体锁的对象进行获取/释放锁

当线程尝试获得锁之后,将更新java的对象头新增锁的标识,表示这个锁已经被这个线程获取,其他线程将阻塞

为了减少获得锁和释放锁带来的性能消耗,在Java SE 1.6之后引入了 偏向锁轻量级锁,锁一共有4种状态

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了使得获得锁的代价更低,所以引入了偏向锁:

当一个线程访问同步块获得锁后,会在对象头和栈帧中记录偏向锁的线程id
以后只要是该进程获得和释放锁都不在需要进行CAS操作,而是只要判断是这个线程id就可以

如果判断失败,则需要判断 偏向锁标识是否为1,如果是则通过CAS尝试将线程id修改为当前id,否则通过CAS竞争获得锁(修改线程id+偏向锁标识1)

file

轻量级锁

当另一个线程获取偏向锁失败后,说明已经有线程占用了锁,那么这个线程就会尝试自旋获取锁,此时则升级为了轻量级锁

重量级锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成 功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁.

file

重量级锁会使得线程阻塞,等待线程被唤醒释放

锁的优缺点对比

file

volatile关键字

volatile关键字将会使得一个变量的单次读写都是实时对其他线程同步的

volatile特性

volatile只是对单次读和写做出了同步,只能保证单次的读/写 是原子性的

回到一开始的例子

public class Main {
    static volatile int a = 1;

    public static void main(String[] args) throws Exception {
        Runnable r = () -> {
            for (int i = 0; i < 100000; i++) {
                //读取a的值
                a = a + 1;
            }
        };
        Thread t1 = new Thread(r);
        t1.start();
        Thread t2 = new Thread(r);
        t2.start();
        t1.join();
        t2.join();
        System.out.println(a);
    }
}

在这个例子中,并不能保证a的值就是20001,而是依然小于,那是因为 a=a+1 是2个步骤:

  • 线程1读取a的值,因为加了volatile关键字,所以会立即读取到主内存的最新值,比如是1
  • 线程2读取a的值,因为加了volatile关键字,所以会立即读取到主内存的最新值,比如是1
  • 线程1将a的值+1,然后将a=2的值立即写入到主内存
  • 线程2依然会将值+1,然后将a=2的值立即写入到主内存

这样依然会造成a的值不正确的情况
总而言之,volatile自身具有以下特性

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写 入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不 具有原子性。

volatile内存语义

线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程 发出了(其对共享变量所做修改的)消息。
线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile 变量之前对共享变量所做修改的)消息。
线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过 主内存向线程B发送消息。

volatile原理

在上面我们了解到了,多线程的变量共享问题主要问题在于多了一个线程本地内存,volatile关键字会额外给CPU指令增加上LOCK前缀

  • 在cpu执行时,LOCK会使得操作的变量立即回写回内存,这样可以保证更新变量后,内存永远存储一个最新的变量值
  • 在cpu回写之后,会使得cpu cache的变量缓存立即失效,这样可以保证其他线程读取的变量不会是缓存,而是是最新的变量值.

为了实现volatile内存语义,JMM将限制重排序:
file

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总 数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
    例如:
    file

懒汉单例模式

在java程序中,懒汉单例的实现如下:

package org.example;

public class UnsafeLazyInstance {
    private static UnsafeLazyInstance instance;
    public static UnsafeLazyInstance getInstance() {
        if (instance == null) {
            instance = new UnsafeLazyInstance();
        }
        return instance;
    }
}

这个单例并非是线程安全的,主要有2个问题
1:new 一个对象,这个步骤并不是原子性的,创建对象的步骤为:

  • 分配对象内存空间
  • 设置instance指向对象的内存空间
  • 初始化对象

2:getInstance这个方法是非同步方法,在多线程同时调用时,可能会2个线程都进入到instance==null,然后创建2个对象

基于volatile和synchronized的双重解决方案

package org.example;

public class SafeLazyInstance {
    private static volatile SafeLazyInstance instance;

    public static SafeLazyInstance getInstance() {
        if (instance == null) {
            synchronized (SafeLazyInstance.class){
                if (instance == null){
                    instance = new SafeLazyInstance();
                }
            }
        }
        return instance;
    }
}

为什么synchronize放在了if里面?
因为如果声明到方法中的话,每次调用getInstance都会加锁,但是实际上不需要加锁,因为大多数情况都是只需要返回instance对象,而且instance除了初始化,其他时候都不会被修改

为什么synchronize需要双重检查?
因为方法并没有实现同步,可能会出现多次调用进入==null的情况

基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在 执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。 基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为 Initialization On Demand Holder idiom)。

package org.example;

public class SafeLazyInstance {
    private static class InstanceHolder {//额外加载一个InstanceHolder类
        public static final SafeLazyInstance instance = new SafeLazyInstance();//final常量,确保只会赋值一次
    }

    public static SafeLazyInstance getInstance() {
        return InstanceHolder.instance;
    }
}

java锁

Lock接口

而Java SE 5之后,并发包中新增 了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功 能,只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提 供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以 及超时获取锁等多种synchronized关键字所不具备的同步特性。


public class Main {
    static volatile int a = 1;

    public static void main(String[] args) throws Exception {
        Lock lock = new ReentrantLock();
        Runnable r = () -> {
            for (int i = 0; i < 100000; i++) {
                lock.lock();
                try{
                    //读取a的值
                    a = a + 1;
                }finally {
                    lock.unlock();
                }
            }
        };
        Thread t1 = new Thread(r);
        t1.start();
        Thread t2 = new Thread(r);
        t2.start();
        t1.join();
        t2.join();
        System.out.println(a);
    }
}

通过锁也可以实现synchronized关键字的功能,但是2者之间还是有一些区别的

Lock接口提供的synchronized关键字不具备的主要特性

file

当然,java除了ReentrantLock之外,还有Mutex,读写锁等,
读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读 线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写 锁,使得并发性相比一般的排他锁有了很大提升。

这种不另外说明

正文到此结束
本文目录