线程同步机制一:内部锁和显式锁

Published on 2024-08-30 18:58 in 分类: 博客 with 狂盗一枝梅
分类: 博客

Java多线程中的同步机制,即当多个线程同时竞争访问共享资源时,如何保证线程安全,在此基础上还要兼顾并发访问的性能问题,这是一个非常棘手且重要的问题。

一、线程安全

1. 举例

举个很简单的生活中的例子,我们去淘宝购物,店里还有最后一件商品,这时候同时有两个顾客下了单,如果两个顾客都买到了一件商品,那就出现了“超卖”的现象,对于淘宝这个购物平台来说,就出现了“线程安全性问题”。

现在模拟一下这个过程:张三和李四同时去商店购买还剩下一件库存的商品

import lombok.extern.slf4j.Slf4j;

/**
 * @author kdyzm
 * @date 2024/8/28
 */
@Slf4j
public class ShoppingSafe {

    public static void main(String[] args) {
        //店铺初始化,商品数量为1
        Store store = new Store(1);
        //顾客初始化,张三和李四两个顾客
        Customer zhangsan = new Customer(store);
        Customer lisi = new Customer(store);
        //两个顾客同时购买
        zhangsan.buy();
        lisi.buy();

    }

    /**
     * 店铺类
     */
    static class Store {

        private Integer goodsCount;

        public Store(Integer goodsCount) {
            this.goodsCount = goodsCount;
        }

        public void sold() {
            if (goodsCount > 0) {
                //模拟处理商品卖出流程,耗时一秒钟
                LockSupport.parkUntil(System.currentTimeMillis()+1000);
                this.goodsCount--;
                log.info("卖出一件商品,剩余商品数:{}", goodsCount);
            } else {
                log.info("商品库存不足,停止售卖");
            }

        }

    }

    /**
     * 顾客类
     */
    static class Customer implements Runnable {

        private Store store;

        public Customer(Store store) {
            this.store = store;
        }

        @Override
        public void run() {
            this.store.sold();
        }

        public void buy() {
            new Thread(this).start();
        }
    }

}

运行结果:

2024-08-28 15:47:53.292 [INFO ] [Thread-2  ] - 卖出一件商品,剩余商品数:0
2024-08-28 15:47:53.292 [INFO ] [Thread-1  ] - 卖出一件商品,剩余商品数:0

为了能够稳定重现超卖场景,我加了一行代码来模拟商品卖出的过程(如果没有这行代码,将要重试很多次才能重现超卖场景)

LockSupport.parkUntil(System.currentTimeMillis()+1000);

2. 分析

来分析下为什么会出现线程安全性问题,在上面的例子中,毫无疑问,超卖了,肯定就是卖方的代码有问题,仔细看看卖出的方法

public void sold() {
    if (goodsCount > 0) {
        //模拟处理商品卖出流程,耗时一秒钟
        LockSupport.parkUntil(System.currentTimeMillis()+1000);
        this.goodsCount--;
        log.info("卖出一件商品,剩余商品数:{}", goodsCount);
    } else {
        log.info("商品库存不足,停止售卖");
    }
}

似乎没啥问题啊。。。其实只是在单线程环境下没问题。是否允许卖出商品,有一个很关键的判断条件:

//1.判断商品数量 
if (goodsCount > 0) {
     //2.卖出并操作goodsCount 减一
 }else{
     //不卖了
 }

**判断商品数量商品数量减一**是两个操作,在高并发状态下,如果两个线程同时读取商品数量,发现都是1,那自然就都能进入if方法块,然后执行卖出逻辑,最后出现超卖的情况

3. 解决方法

解决方法很简单,张三来买商品的时候,系统开始判断商品数量之前就开始停止接受其它顾客的下单,等张三买完商品并且库存减少了一之后,再继续接受下单请求。在java中,一个关键字就能解决:synchronized,这样改造下代码

public synchronized void sold() {
    if (goodsCount > 0) {
        log.info("开始卖出商品");
        LockSupport.parkUntil(System.currentTimeMillis() + 1000);
        this.goodsCount--;
        log.info("卖出一件商品,剩余商品数:{}", goodsCount);
    } else {
        log.info("商品库存不足,停止售卖");
    }"
}

运行结果

2024-08-28 16:21:38.998 [INFO ] [Thread-1  ] - 开始卖出商品
2024-08-28 16:21:40.004 [INFO ] [Thread-1  ] - 卖出一件商品,剩余商品数:0
2024-08-28 16:21:40.008 [INFO ] [Thread-2  ] - 商品库存不足,停止售卖

synchronized将售卖方法sold变成了“同步方法”,实际上是给该方法加上了一个“监视器锁”,任何线程想要访问该方法,必须先拿到该“监视器锁”,张三拿到了,执行完方法才会释放锁;李四需要等待张三释放了锁,拿到监视器锁之后,才能进入该方法执行方法代码。这样就保证了线程在这块代码逻辑中是串行执行的,也就保证了线程的安全性。

从线程的状态角度上来看,拿到锁的线程经历了NEW->RUNNABLE->TIMED_WAITING->TERMINATED 的状态转化流程;而被没拿到锁的线程经历了NEW->RUNNABLE->BLOCKED->RUNNABLE->TIMED_WAITING->TERMINATED 的状态转化流程,关于线程状态,可以参考之前的文章:线程和线程的六种状态

4. 线程安全性定义

一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,那么我们就称其是线程安全 (Thread-safe)的,相应地我们称这个类具有线程安全性 (Thread Safety)。反之,如果一个类在单线程环境下运作正常而在多线程环境下则无法正常运作,那么这个类就是非线程安全的。

出现了线程安全性问题,一般需要线程同步机制确保线程安全。

二、并发编程的三大问题

一个类如果不是线程安全的,我们就说它在多线程环境下直接使用存在线程安全问题 。并发编程中存在着三大问题:原子性、可见行、有序性。

1、原子性

原子(Atomic)的字面意思是不可分割的(Indivisible)。所谓“不可分割”,是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间效果。换一句通俗易懂的话来说,原子操作是指不可被中断的单个操作,即使在多线程环境下也不会被其他线程干扰。原子操作是线程安全的,可以保证数据的一致性。

举个例子说明下原子性:常见的long类型的变量自增操作:long a = 1;a++;,如果遵循了原子性操作,那么在多线程环境下,a变量只会对外展示出两种状态

  1. a变量未自增前,值为1
  2. a变量自增后,值为2

Java中有两种方式来实现原子性:一种是使用锁,另外的就是使用CAS“硬件锁”。

锁:java的内部锁synchronized和显示锁都具有排他性,它能够保障一个共享变量在任意一个时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突的可能,即消除了竞态。

CAS(Compare-and-Swap)指令:CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层次实现的,而CAS是直接在硬件(处理器和内存)这一层次实现的,它可以被看作“硬件锁”。基于CAS指令java中实现了很多“原子类”,都可以保证原子操作。

基于锁的原子性操作

@Slf4j
public class BaseLock {

    public static void main(String[] args) {
        Counter counter = new Counter();
        new Thread(counter).start();
        new Thread(counter).start();
    }

    static class Counter implements Runnable {
        long a = 0;

        @Override
        public void run() {
            for (int i = 0; i < 1000000; i++) {
                synchronized (this) {
                    a++;
                    log.info("a自增后的结果:{}", a);
                }
            }
        }
    }
}

运行结果:

image-20240909220728787

基于原子类的原子性操作

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.atomic.AtomicLong;

@Slf4j
public class BaseAtomicClass {

    public static void main(String[] args) {
        Counter counter = new Counter();
        new Thread(counter).start();
        new Thread(counter).start();
    }

    static class Counter implements Runnable {
        AtomicLong a = new AtomicLong(0L);
        @Override
        public void run() {
            for (int i = 0; i < 1000000; i++) {
                long l = a.incrementAndGet();
                log.info("自增后的结果:{}", l);
            }
        }
    }

}

运行结果:

image-20240909221554093

两种操作均可以实现线程安全,实际测试中,发现使用原子类的操作效率更高。

volatile关键字不具备原子性语义

volatile关键字具有可见性和有序性语义,但是并不具备原子性语义,看以下代码

@Slf4j
public class BaseLock {

    public static void main(String[] args) {
        Counter counter = new Counter();
        new Thread(counter).start();
        new Thread(counter).start();
    }

    static class Counter implements Runnable {
        volatile long a = 0;

        @Override
        public void run() {
            for (int i = 0; i < 1000000; i++) {
                    a++;
                    log.info("a自增后的结果:{}", a);
            }
        }
    }
}

不使用synchronized或者无锁机制来保证线程安全性,只是使用volatile关键字是否能保证结果的准确性?答案是否定的。

image-20240912133954210

a++的操作其实是由三步组成的,具体如下。

1)从主内存中获取a的值,然后缓存至线程工作内存中。

2)在线程工作内存中为a进行加1的操作。

3)将a的最新值写入主内存中。

每一个操作都是原子性操作,但是合起来就不是,因为在执行的中途很有可能会被其他线程打断,这并非volatile关键字能够解决的问题,必须使用锁机制确保操作的线程安全。

2、可见性

在多线程环境中,可见性是指当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改后的值。可见性是多线程编程中一个重要的概念,确保线程之间共享数据的一致性。

在多核处理器系统中,每个线程都有自己的缓存,当一个线程修改了共享变量的值时,这个修改可能会先存储在它自己的缓存中而不是立即写入主存,其他线程在读取这个共享变量时可能会读取到旧的数值,导致数据不一致的情况。

可见性导致的线程安全性案例很难重现。。但是一旦发生一次,就会很难排查问题所在,所以经验之谈很重要,下面附上一段出自于汪文君老师的《java高并发编程详解:多线程与架构设计》一书中可稳定重现可见性问题的代码

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.TimeUnit;

@Slf4j
public class VolatileFoo {
    /**
     * init_value的最大值
     */
    final static int MAX = 5;

    /**
     * init_value的初始值
     */
    static int init_value = 0;

    public static void main(String[] args) {
        //启动一个Reader线程,当发现local_value和init_value不同时,则输出init_value被修改的信息
        new Thread(new ReaderTask(), "Reader").start();
        //启动Updater线程,主要用于对init_value的修改,当local_value>=5的时候则退出生命周期
        new Thread(new UpdateTask(), "Updater").start();
    }

    static class ReaderTask implements Runnable {
        @Override
        public void run() {
            int localValue = init_value;
            while (localValue < MAX) {
                if (init_value != localValue) {
                    log.info("The init_value is updated to {}", init_value);
                    //对localValue进行重新赋值
                    localValue = init_value;
                }
            }
        }
    }

    static class UpdateTask implements Runnable {
        @Override
        public void run() {
            int localValue = init_value;
            while (localValue < MAX) {
                //修改init_value
                log.info("The init_value will be changed to {}", ++localValue);
                init_value = localValue;
                try {
                    //短暂休眠,目的是为了使Reader线程能够来得及输出变化内容
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

}

以上代码直接运行,结果如下

image-20240911224829511

写线程将init_value的数值从1改到了5,但是读线程一直未察觉到int_value值的变化,从而陷入了死循环中。如何解决这个问题?直接将int_value值使用volatile关键字修饰即可

    /**
     * init_value的初始值
     */
    volatile static int init_value = 0;

修改后重新运行,可得到结果

image-20240911225108886

可以看到写线程和读线程依次修改和读取,最终两个线程正常结束了任务。

那么volatile关键字是什么东西,加上它为什么就解决了这个问题呢?

在这里简单解释下这个问题,根据java内存模型JMM的相关知识,两个线程都有都有独立的工作内存,int_value存储在主内存,两个线程不能直接操作主内存,在其工作内存分别有int_value的副本,写线程操作完成后只是修改了自己工作内存的数据,所以写线程的操作对读线程不可见,最终出现了可见性问题。

使用volatile关键字解决了该问题,是因为volatile具有可见性的语义。当一个变量被volatile关键字修饰时,对于共享资源的读操作会直接在主内存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中。

使用volatile关键字之后两个线程的执行流程:

  1. Reader线程从主内存中获取init_value的值为0,并且将其缓存到本地工作内存中。

  2. Updater线程将init_value的值在本地工作内存中修改为1,然后立即刷新至主内存中。

  3. Reader线程在本地工作内存中的init_value失效(反映到硬件上就是CPU的L1或者L2的Cache Line失效)。

  4. 由于Reader线程工作内存中的init_value失效,因此需要到主内存中重新读取init_value的值。

3、有序性

所谓有序性是指程序代码在执行过程中的先后顺序,由于Java在编译器以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序,例如以下代码

int x = 1;
int y = 2;
x++;
y++;

从编写程序的角度来看上面的代码肯定是顺序执行下来的,但是在JVM真正地运行这段代码的时候未必会是这样的顺序,比如x在y后面执行,这种情况就是我们说的指令重排序(Instruction Recorder)。

一般来说,处理器为了提高程序的运行效率,可能会对输入的代码指令做一定的优化,它不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序来进行,但是它会保证程序的最终运算结果是编码时所期望的那样,比如上文中的x与y不管它们的执行顺序如何,执行完上面的四行代码之后得到的结果肯定都是x=2,y=3。

当然对指令的重排序要严格遵守指令之间的数据依赖关系,并不是可以任意进行重排序的,比如下面的代码片段:

int x = 10;
int y = 0;
x++;
y=x+1;

x++必须在y=x+1之前执行,否则程序结果就不符合预期,在单线程情况下,无论怎样的重排序最终都会保证程序的执行结果和代码顺序执行的结果是完全一致的。但是在多线程环境下,如果有序性得不到保证,那么很有可能就会出现非常大的问题,比如下面的代码片段:

private boolean initialized = false;
private Context context;
public Context load(){
    if(!initialized){
        context=loadContext();
        initialized = true;
    }
    return context;
}

上述这段代码使用boolean变量initialized来控制context是否已经被加载过了,在单线程下无论怎样的重排序,最终返回给使用者的context都是可用的。如果在多线程的情况下发生了重排序,比如context=loadContext()的执行被重排序到了initialized=true的后面,那么这将是灾难性的了。比如第一个线程首先判断到initialized=false,因此准备执行context的加载,但是它在执行loadContext()方法之前二话不说先将initialized置为true然后再执行loadContext()方法,那么如果另外一个线程也执行load方法,发现此时initialized已经为true了,则直接返回一个还未被加载成功的context,那么在程序的运行过程中势必会出现错误。

如何解决该问题?答案还是volatile关键字,volatile关键字不仅有可见性的语义,它还能禁止指令重排序。修改后的代码如下

private volatile boolean initialized = false;
private Context context;
public Context load(){
    if(!initialized){
        context=loadContext();
        initialized = true;//阻止重排序
    }
    return context;
}

如果对initialize布尔变量增加了volatile的修饰,那就意味着initialize=true的时候一定是执行且完成了对loadContext()的方法调用,这样就不会有指令重排序的问题。

三、线程同步机制简介

什么是线程同步机制?

举个例子:公路上行驶的车辆只有遵守交通规则才能够达到其目的——安全地到达目的地,在多线程编程中,线程同步机制就是多线程运行保障线程安全性的交通规则。

线程同步机制是一套用于协调线程间的数据访问(Data access)及活动(Activity)的机制,该机制用于保障线程安全以及实现这些线程的共同目标。

1. 竞态

多线程编程中经常遇到的一个问题就是对于同样的输入,程序的输出有时候是正确的而有时候却是错误的。这种一个计算结果的正确性与时间有关的现象就被称为竞态 (Race Condition)。

一个类如果能够导致竞态,那么它就是非线程安全的;而一个类如果是线程安全的,那么它就不会导致竞态。

竞态有两种常见的模式:read-modify-write(读—改—写)和check-then-act(检测而后行动)。

read-modify-write

read-modify-write模式,也就是读-改-写操作,该操作可以被细分为这样几个步骤:读取一个共享变量的值(read),然后根据该值做一些计算(modify),接着更新该共享变量的值(write)。自增操作就是典型的read-modify-write模式体现:假设有个变量sequence,现在使用sequence++自增

load(sequence, r1); // 指令①read:从内存将sequence的值读到寄存器r1(读取共享变量值)
increment(r1); // 指令② modify:将寄存器r1的值增加1(根据共享变量值做一些计算)
store(sequence, r1); // 指令③ write:将寄存器r1的内容写入sequence对应的内存空间(更新共享变量)

一个线程在执行完指令①之后到开始(或者正在)执行指令②的这段时间内其他线程可能已经更新了共享变量(sequence)的值,这就使得该线程在执行指令②时使用的是共享变量的旧值(读脏数据)。接着,该线程把根据这个旧值计算出来的结果更新到共享变量,而这又使得其他线程对该共享变量所做的更新被“覆盖”,即造成了更新丢失。

check-then-act

check-then-act(检测而后行动)操作,该操作可以被细分为这样几个步骤:读取某个共享变量的值,根据该变量的值决定下一步的动作是什么。

if (sequence >= 999) { // 子操作①check:检测共享变量的值
  sequence = 0; // 子操作②act:下一步的操作
} else {
  sequence++;
}

一个线程在执行完子操作①到开始(或者正在)执行子操作②的这段时间内,其他线程可能已经更新了共享变量的值而使得if语句中的条件变为不成立,那么此时该线程仍然会执行子操作②,尽管这个子操作所需的前提(if语句中的条件)实际上并未成立。

2. 锁的概念

我们知道线程安全问题的产生前提是多个线程并发访问共享变量、共享资源(以下统称为共享数据 )。于是,我们很容易想到一种保障线程安全的方法——将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。锁(Lock)就是利用这种思路以保障线程安全的线程同步机制。

排它锁和共享锁

在并发编程中,排它锁(Exclusive Lock)和共享锁(Shared Lock)是两种常见的锁机制,用于控制对共享资源的访问。

排它锁(Exclusive Lock):排它锁也称为独占锁,是一种只允许一个线程或进程独占资源的锁。当一个线程获取了排它锁后,其他线程无法再获取该资源的锁,直到持有排它锁的线程释放锁。排它锁用于保护对资源的写访问,确保在写操作期间不会被其他线程读取或写入。排它锁适用于需要独占资源进行写操作的场景,例如数据库中的行级锁。

共享锁(Shared Lock):共享锁是一种允许多个线程或进程共享资源的锁。当一个线程获取了共享锁后,其他线程也可以获取相同资源的共享锁,允许多个线程同时读取资源,但不允许写操作。共享锁用于保护对资源的读访问,确保在读操作期间不会被其他线程写入。共享锁适用于允许多个线程同时读取资源但不允许写入的场景,例如读取共享数据并发访问。

在 Java 中,ReentrantReadWriteLock 类提供了读写锁的实现,其中包含读锁(共享锁)和写锁(排它锁),可以根据需要选择性地使用这两种锁来控制对共享资源的访问。而常见的synchronized是一种排它锁。

可重入性

可重入锁指的是同一个线程可以多次获取同一个锁,而不会发生死锁的情况。当线程持有锁时,可以再次获取同一个锁,而不会被自己持有的锁所阻塞。如果一个线程持有一个锁的时候还能够继续成功申请该锁,那么我们就称该锁是可重入的 (Reentrant),否则我们就称该锁为非可重入的 (Non-reentrant)

可重入性可以使用以下伪代码来描述:

void metheadA(){
  acquireLock(lock); // 申请锁lock
  // 省略其他代码
  methodB();
  releaseLock(lock); // 释放锁lock
}

void metheadB(){
  acquireLock(lock); // 申请锁lock
  // 省略其他代码
  releaseLock(lock); // 释放锁lock
}

方法methodA使用了锁lock,该锁引导的临界区代码又调用了另外一个方法methodB,而方法methodB也使用了lock。那么,这就产生了一个问题:methodA的执行线程持有锁lock的时候调用了methodB,而methodB执行的时候又去申请锁lock,而lock此时正被当前线程持有(未被释放)。那么,此时methodB究竟能否获得(申请成功)lock呢?可重入性就描述了这样一个问题。

我们常用的内部锁synchronized和显式锁Lock都是可重入锁synchronized 关键字具有可重入性是因为在每个锁对象上都有一个计数器,用于记录当前线程获取该锁的次数。当一个线程第一次获取锁时,计数器会加一;当这个线程再次获取同一个锁时,计数器会再次加一。只有当计数器归零时,锁才会被释放。

这种机制使得同一个线程可以重复获取同一个锁,而不会造成死锁。可重入性使得编写线程安全的代码变得更加方便,因为同一个线程在持有锁的情况下可以调用其他使用了 synchronized 关键字的方法,而无需担心死锁的问题。

Java中有非可重入锁码?到现在我还没发现。。。

公平锁和非公平锁

锁可以被看作多线程程序访问共享数据时所需持有的一种排他性资源。因此,资源的争用、调度的概念对锁也是适用的。

Java平台中锁的调度策略也包括公平策略和非公平策略,相应的锁就被称为公平锁和非公平锁 。

公平锁:公平锁是指多个线程按照它们发出请求的顺序来获取锁,即按照先来后到的顺序获取锁。当一个线程请求一个公平锁时,如果锁当前可用,该线程会立即获得锁;如果锁正在被其他线程持有,该线程会排队等待,直到前面所有等待的线程都获得了锁。公平锁保证不会有线程在一直获取不到锁的情况下饥饿,但可能会导致额外的性能开销。

非公平锁:非公平锁是指多个线程尝试获取锁时,不考虑等待队列中其他线程的顺序,可能会导致新请求的线程插队,优先获取锁。当一个线程请求一个非公平锁时,如果锁当前可用,该线程会立即获得锁,不考虑其他线程的等待情况。非公平锁可能会导致某些线程长时间无法获取锁,存在饥饿的可能,但通常比公平锁具有更好的性能。

内部锁synchronized属于非公平锁,而显式锁Lock则既支持公平锁又支持非公平锁。

锁的粒度

一个锁实例所保护的共享数据的数量大小就被称为该锁的粒度 (Granularity)。一个锁实例保护的共享数据的数量大,我们就称该锁的粒度粗, 否则就称该锁的粒度细

锁的粒度粗往往意味着临界区比较大,持有锁的线程长时间占据锁不释放,势必会导致其它线程长时间的等待最终导致并发效率低下。一个比较典型的例子就是同步方法和同步代码块:同步方法是一种锁粒度较粗的同步方式,因为整个方法体被同步,当一个线程访问同步方法时,会获取整个方法的锁,即使方法中只有一小部分代码需要同步,其他线程也无法同时访问整个方法

public synchronized void synchronizedMethod() {
	//临界区代码
}

而同步代码块只在需要锁控制的代码块加锁,实现了细粒度的锁控制,细粒度的锁可以减小锁的竞争范围,提高程序的并发性能,避免不必要的线程等待

public void synchronizedMethod() {
    //其它代码
    synchronized (this) {
        // 临界区代码
    }
    //其它代码
}

锁泄露

锁泄露(Lock leakage)是指在并发编程中,由于某些原因导致锁未被正确释放而持续占用的情况。锁泄露可能导致严重的性能问题和资源泄漏,甚至引发死锁等严重的并发问题。

锁泄露可能得原因有:

  1. 异常情况下未释放锁:如果在锁保护的代码块中发生了异常,导致锁无法被释放,就会发生锁泄露。
  2. 逻辑错误:在代码中逻辑错误导致在某些情况下锁未被正确释放。
  3. 死循环:如果在锁保护的代码块中出现死循环,导致永远无法释放锁,就会发生锁泄露。
  4. 线程泄露:当线程在持有锁的情况下被意外终止或长时间阻塞,导致锁一直被占用。

最好的方法就是使用try-finally块或者try-with-resources语句来确保锁的正确释放。关于try-with-resources,可以参考我之前的文章:java中的try-with-resource语法

3. 临界区

一个线程在访问共享数据前必须申请相应的锁(许可证),线程的这个动作被称为锁的获得(Acquire)。一个线程获得某个锁(持有许可证),我们就称该线程为相应锁的持有线程 ,一个锁一次只能被一个线程持有。锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放(Release)相应的锁

锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区(Critical Section)。因此,共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。

四、内部锁:synchronized

在上面的商品售卖的例子中已经使用synchronized关键字解决了商品超卖的问题,商品超卖的本质原因是什么?实际上就是多个线程同时访问并修改了商品数量goodsCount这个共享资源。synchronized关键字会给方法加上一把“排它锁”,当某个线程获得了监视器锁之后,其它线程想要获取监视器锁,就要阻塞等待。

简单来说,多个线程同时访问同一个对象的某个方法时,如果该方法中存在对共享资源的操作,则可能引发线程安全问题,使用synchronized同步机制,可以解决该问题。

synchronized关键字是一种可重入排它锁,而且是非公平锁,synchronized有三种写法:同步方法、同步代码块、同步静态方法

同步方法

还是以之前的商品售卖为例,最后的解决方式就是同步方法的写法

public synchronized void sold() {
    if (goodsCount > 0) {
        log.info("开始卖出商品");
        LockSupport.parkUntil(System.currentTimeMillis() + 1000);
        this.goodsCount--;
        log.info("卖出一件商品,剩余商品数:{}", goodsCount);
    } else {
        log.info("商品库存不足,停止售卖");
    }
}

上述代码的调用流程如下:

(1)线程A调用sold()方法,进入方法体前,先试图获取当前对象的监视器锁。

(2)如果当前对象的监视器锁没有被占用,则线程A会获取当前对象的监视器锁,然后进入sold()方法;否则自旋等待获得锁(一般为抢占式,无须排队)。

(3)其他线程调用sold()方法时,执行顺序相同。

什么是“当前对象的监视器锁”?其实就是"this",上面的代码和下面的代码等效

public void sold() {
    synchronized(this){
        if (goodsCount > 0) {
            log.info("开始卖出商品");
            LockSupport.parkUntil(System.currentTimeMillis() + 1000);
            this.goodsCount--;
            log.info("卖出一件商品,剩余商品数:{}", goodsCount);
        } else {
            log.info("商品库存不足,停止售卖");
        }
    }
}

这种方式也就是同步代码块的实现方式。

同步代码块

同步方法的弊端就是它对整个方法都加了锁,实际业务场景中,使用共享资源的只是部分代码,没有必要为整个方法都加锁,这会降低并发性能。

使用同步代码块模式,可以在方法中真正需要使用共享资源时再获取监视器锁,共享资源访问结束马上释放锁,这样就节省了对象监视器锁的占用时间,可以有效提高并发性能。

监视器锁内置于Object对象底层,所有对象的根都源于Object,因此所有对象都有监视器锁。在上面的例子中,使用了synchronized(this)的写法,当然还可以锁其它对象:使用其它Object类对象,比使用this对象更加灵活。

static class Store {
        private Integer goodsCount;

        private final Object obj = new Object();
        
        public Store(Integer goodsCount) {
            this.goodsCount = goodsCount;
        }

        public  void sold() {
            synchronized (obj){
                if (goodsCount > 0) {
                    log.info("开始卖出商品");
                    LockSupport.parkUntil(System.currentTimeMillis() + 1000);
                    this.goodsCount--;
                    log.info("卖出一件商品,剩余商品数:{}", goodsCount);
                } else {
                    log.info("商品库存不足,停止售卖");
                }
            }
        }
    }

除了普通的Object对象,还可以使用当前类的类监视器锁,也就是类锁

    static class Store {

        private Integer goodsCount;

        public Store(Integer goodsCount) {
            this.goodsCount = goodsCount;
        }

        public  void sold() {
            synchronized (Store.class){
                if (goodsCount > 0) {
                    log.info("开始卖出商品");
                    LockSupport.parkUntil(System.currentTimeMillis() + 1000);
                    this.goodsCount--;
                    log.info("卖出一件商品,剩余商品数:{}", goodsCount);
                } else {
                    log.info("商品库存不足,停止售卖");
                }
            }
        }

    }

关于类锁,看下节内容。

同步静态方法

首先看下赫赫有名的单例模式(关于单例模式,看文章:设计模式(一):单例模式(上))的一种写法:

public static synchronized Object instance() {

}

这就是同步静态方法的写法。(单例模式一般不会使用这种效率低下的写法,这里纯纯只是作为同步静态方法的一个案例。)

从上面的同步方法章节可以知道,同步方法实际上等效于使用了当前对象的监视器锁,也就是“对象锁”,那同步静态方法用的谁的锁呢?毕竟静态方法是作用于全局的,没有对象也可以调用,所以可以猜到,它实际上等价于以下写法

public static Object instance() {
	synchronized(当前类.class){
        
    }
}

也就是“类锁”了,要注意,类锁和对象锁是不一样的,虽然所有的类也都是对象,但是类锁关联的监视器锁是和类对象本身关联的,这与对象无关。

java.lang.Object是所有对象的根,在Object的每个实例中都内置了对象监视器锁。

java.lang.Class的父类也是Object,在每个类型中也内置了一把锁,这与对象无关。

五、显式锁:ReentrantLock

显式锁是自JDK 1.5开始引入的排他锁。作为一种线程同步机制,其作用与内部锁相同。它提供了一些内部锁所不具备的特性,但并不是内部锁的替代品。

显式锁 (Explicit Lock)是java.util.concurrent.lcoks.Lock接口的实例,ReentrantLock则是其默认实现。

ReentrantLock使用方式如下所示

private final Lock lock=new ReentrantLock(); // 创建一个Lock接口实例
……

lock.lock(); // 申请锁lock
try{
  // 在此对共享数据进行访问,即此区域为临界区代码
  ……
}finally{
  // 总是在finally块中释放锁,以避免锁泄漏
  lock.unlock(); // 释放锁lock
}

为啥叫它“显式锁”?正是因为相比较synchronized这个内部锁来说,显式锁的锁获取和锁释放都需要手动做,不像synchronozed关键字一样只需要用一个代码块包起来就行。

需要注意的是,为了方式锁泄露,需要将释放锁的代码放到finally块中。

1. 调度策略

ReentrantLock是一种可重入排它锁,它和内部锁不同的是,它既支持公平锁,也支持非公平锁,只需要创建实例的时候构造方法传参指明即可:

image-20240830153226388

从源码可以看到ReentrantLock默认是非公平锁,构造传参是true的时候,才会创建公平锁实例。为啥会默认使用非公平策略呢?因为公平锁的开销比使用非公平锁的开销要大,所以显式锁默认使用的是非公平调度策略

公平锁适合于锁被持有的时间相对长或者线程申请锁的平均间隔时间相对长的情形。

2. 显式锁和内部锁的比较

对于大部分场景下,使用synchronized关键字可以简化代码并减少出错的可能性,已经完全能够满足需求;但是对于部分需要灵活控制锁的获取和释放的场景,就需要使用显式锁了。

比如要使用公平锁的时候,在多数线程持有一个锁的时间相对长或者线程申请锁的平均时间间隔相对长的情况最好使用公平锁,那就要使用显式锁才能实现;再比如显式锁支持tryLock,当获取到锁的时候才会返回true,否则返回false,这种机制可以使得尝试获取锁失败的线程可以去干其他的事情而不会被阻塞

Lock lock = ...;
if (lock.tryLock()) {
  try {
    // 在此访问共享数据
  } finally {
    lock.unlock();
  }
} else {
  // 执行其他操作
}

显式锁并非是内部锁的替代品,它们各有所长,显式锁提供了更多的灵活性,但也需要更多的代码来管理锁的获取和释放;而内部锁则更加简单易用,适合一些基本的线程同步需求。在实际开发中,需要根据具体的需求和场景选择适合的锁机制。

3. 改进型锁:读写锁

无论是内部锁synchronized还是显式锁ReentrantLock,都是排它锁,锁的排他性使得多个线程无法以线程安全的方式在同一时刻对共享变量进行读取(只是读取而不更新),这不利于提高系统的并发性。

读写锁 (Read/Write Lock)是一种改进型的排他锁,也被称为共享/排他(Shared/Exclusive)锁。读写锁允许多个线程可以同时读取(只读)共享变量,但是一次只允许一个线程对共享变量进行更新(包括读取后再更新)。读写锁实际上是针对读多写少的情况下减少线程竞争的一种改进锁型。

使用读写锁,任何线程读取共享变量的时候,其他线程无法更新这些变量;一个线程更新共享变量的时候,其他任何线程都无法访问该变量。

读写锁的功能是通过其扮演的两种角色——读锁(Read Lock)和写锁(Write Lock)实现的。

获得条件 排他性 作用
读锁 相应的写锁未被任何线程持有 对读线程是共享的,对写线程是排他的 允许多个读线程可以同时读取共享变量,并保障读线程读取共享变量期间没有其他任何线程能够更新这些共享变量
写锁 该写锁未被其他任何线程持有并且相应的读锁未被其他任何线程持有 对写线程和读线程都是排他的 使得写线程能够以独占的方式访问共享变量

读写锁的使用方式:

public class ReadWriteLockUsage {
  private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

  private final Lock readLock = rwLock.readLock();

  private final Lock writeLock = rwLock.writeLock();

  // 读线程执行该方法
  public void reader() {
    readLock.lock(); // 申请读锁
    try {
      // 在此区域读取共享变量
    } finally {
      readLock.unlock(); // 总是在finally块中释放锁,以免锁泄漏
    }
  }

  // 写线程执行该方法
  public void writer() {
    writeLock.lock(); // 申请读锁
    try {
      // 在此区域访问(读、写)共享变量
    } finally {
      writeLock.unlock(); // 总是在finally块中释放锁,以免锁泄漏
    }
  }
}

读写锁适合于在以下条件同时得以满足的场景中使用:

● 只读操作比写(更新)操作要频繁得多;

● 读线程持有锁的时间比较长。

只有同时满足上面两个条件的时候,读写锁才是适宜的选择。否则,使用读写锁会得不偿失(开销)。


#多线程编程 #java
目录
复制 复制成功