JUC从实战到源码:多线程与锁的知识

JUC从实战到源码:多线程与锁的知识

😄生命不息,写作不止

🔥 继续踏上学习之路,学之分享笔记

👊 总有一天我也能像各位大佬一样

🏆 博客首页 @怒放吧德德 To记录领地 @一个有梦有戏的人

🌝分享学习心得,欢迎指正,大家一起学习成长!

转发请携带作者信息 @怒放吧德德(掘金) @一个有梦有戏的人(CSDN)


前言

在之前的文章中,我们已经学到了关于Future以及演变到CompletableFuture的应用,接下来就是我们日常中接触最多的内容:多线程与并发,尽管之前笔者也发布过几篇文章,在CSDN的阅读量也都突破了20w+【多线程与高并发】- synchronized锁的认知,可见是大多数开发者都在关注的点。今天这篇文章属于JUC系列,然而也涉及到多线程与锁的知识,这章就再次来学习一下。

Java中锁的概念

在Java中,锁是一个非常重要的概念。它为我们提供了一种机制,能够在进行并发编程时解决线程间的同步问题。锁基本上是关于共享资源访问控制的一种机制。当一个线程试图访问共享数据时,它需要先获取到锁,然后才能进行操作。如果锁已经被其他线程持有,那么试图获取锁的线程将被阻塞,直到锁被释放。

内置锁:也被称为"监视器锁"或"synchronized锁",它是Java提供的锁机制的原始形式。当一个线程访问某个对象的synchronized方法或代码块时,它会自动获取这个锁。这种锁是非公平的,只有一个线程能够持有,直到其释放。 ReentrantLock: 是Java并发包(java.util.concurrent.locks)中提供的一种锁。它更为灵活,可以设置为公平或非公平的,还可以中断正在等待的线程,或者通过设置超时时间限制线程等待。ReentrantLock需要手动获取锁并释放,如果没有正确释放,就可能导致死锁。

对于synchronized的内容,笔者之前也沉淀了不少文章,这里对于重复的内容不会在继续赘述。以下我贴出一些阅读量较高的文章。

【多线程与高并发】- 线程基础与状态-CSDN博客 【多线程与高并发】- synchronized锁的认知 【多线程与高并发】- 浅谈volatile-CSDN博客 【多线程与高并发】- 锁的机制与底层优化原理_cpp锁底层-CSDN博客

以上文章的内容都是依次递增,建议读者顺序观看,能够很好的对锁有一定的认识。

悲观锁、乐观锁

在Java并发编程中,悲观锁和乐观锁是两种常见的锁定策略,它们对数据的并发访问具有不同的处理方式。接下来我们来学习悲观锁与乐观锁。

悲观锁

悲观锁假设最坏的情况,认为在数据被处理时总会有其它线程来竞争,所以在每次读写数据时都会先上锁,保证同一时间只有一个线程能操作数据,避免数据的冲突。这种锁的实现通常依赖于数据库的锁机制,如行锁、表锁等。Java中的synchronized关键字和Lock都可以看作是悲观锁的实现。

悲观锁的优点是能保证数据的一致性,但缺点是在高并发的场景下,获取锁的线程可能会长时间阻塞,导致并发能力下降。

那么悲观锁适用于什么场景呢?

悲观锁总是认为数据会被其他线程修改,因此在操作数据前总是先获取锁,确保当前线程对数据有独家访问权限。适合在写操作多的场景,通过先加锁来保证数据的准确性。

乐观锁

乐观锁则假设最好的情况,它认为在数据被处理时不会有其它线程来竞争。所以在读取数据时不会加锁,而在写入时才会检查在此期间有无其它线程对数据进行了修改。如果有,则操作失败并进行重试,否则写入数据。乐观锁的实现一般依赖于数据的版本号。每次读取数据时,都会读取数据的版本号,写入时检查版本号是否发生变化。如果版本号一致,则写入,并将版本号+1;如果版本号不一致,则重试。

乐观锁的优点是在并发度不高的场景下,减少了获取锁的开销,提高了并发能力。但在高并发场景下,数据争用激烈,可能需要频繁地进行重试,反而效率较低。

在Java中是通过无锁编程来实现,只是在更新的时候去做一个判断之前有没有别的线程更新了数据。

如果没有被其他线程更新,那么当前线程修改的数据就能写入成功。如果这个数据已经是被其他线程所更新,则就会更具不同的实现方式执行不同的操作,比如说放弃修改、重试抢锁等。

一般采用的规则有

版本号机制Version CAS算法,Java原子类中的递增操作就是通过CAS自旋实现。

那么乐观锁适合哪种场景呢?

乐观锁适合在读操作多的场景,不加锁的特点会使得读操作的性能大幅度提升。这点不难想象,因为加锁是需要耗费性能的,但是读是不会有什么冲突的,只有写操作的介入才会导致数据问题。

*Demo演示锁

接下来我们用几个案例来演示锁相关的内容。

我们可以从阿里巴巴的开发手册中看一下,对于锁这方面的规定。

那么什么是对象锁,什么又是类锁,接下来将用具体的案例来了解。

1 两个线程执行添加synchronized方法

代码如下,首先准备一个资源类Phone,这是个很普通的方法,两个加了synchronized,但是没有进行锁争抢。这里加了睡眠100毫秒是为了让线程a先启动,这样有个顺序逻辑。

java 复制代码
public class LockDemo {
    public static void main(String[] args) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(phone::sendEmail, "thread-a").start();
        // 为了让a线程率先启动
        Thread.sleep(100);
        new Thread(phone::sendSMS, "thread-b").start();
    }
}

// 资源类
class Phone {

    public synchronized void sendEmail() {
        System.out.println("发送邮件");
    }

    public synchronized void sendSMS() {
        System.out.println("发送短信");
    }
}

最后的结果是输出了发送邮件,在输出发送短信。

java 复制代码
发送邮件
发送短信

以上这个输出,我们可能会认为,会不会是因为sendEmail方法执行得比较快,而并不是sendSMS要等待sendEmail方法执行完之后才执行得呢?这个就得对synchronized有个很好得认识,这点我们结合以下第二个案例进行分析。

2 在sendEmail中进行延迟

这回做了个小改动,就是在调用执行发送邮件时候进行延时1秒。

java 复制代码
public synchronized void sendEmail() {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    System.out.println("发送邮件");
}

其他不改动,通过执行之后,我们可以看到,thread-b会等thread-a执行完毕后才执行。

java 复制代码
发送邮件
发送短信

对比了第一第二个案例,第二个案例无非就是在sendEmail方法中加了延时一秒。但是通过执行,我们很好得看出输出的结果,就是sendSMS等待sendEmail方法的执行。

分析:

那么,为什么会出现这样呢?我们要理解,synchronized是把悲观锁,锁的是资源类而不是锁单个方法,当加了synchronized,不是只对这个方法进行加锁,而是在这个类资源中的所有加了synchronized关键字的方法都是只能有个线程来访问。此时就是类锁。

对于1、2案例的总结:

当synchronized加到了方法上,在一个对象中,所有加上了同步锁关键字的方法,只允许一个线程去访问,其他线程只能等待执行。锁的是对象this。

3 相同对象调用有/无锁方法

这个测试是只有一个对象,调用了一个是加了synchronized锁,一个不加锁。代码如下,还是让thread-a执行的是带有synchronized的方法,在此方法内延迟了1秒,在另一个方法没有加锁。

java 复制代码
public class LockDemo {
    public static void main(String[] args) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(phone::sendEmail, "thread-a").start();
        // 为了让a线程率先启动
        Thread.sleep(100);
        new Thread(phone::noSync, "thread-b").start();
    }
}

// 资源类
class Phone {

    public synchronized void sendEmail() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("发送邮件");
    }

    public synchronized void sendSMS() {
        System.out.println("发送短信");
    }

    public void noSync() {
        System.out.println("这个方法没有加锁");
    }
}

很显然先输出没加锁方法的内容。

java 复制代码
这个方法没有加锁
发送邮件

分析:

对比前面两种案例,这第三种虽然都是用了同一个对象,但是一个调用的是同步方法,另一个是普通方法,并不会让thread-b等待thread-a线程。

4 两个对象调用带锁的两个方法

这次我们修改一个方法,我们创建两个对象,分别执行sendEmail和sendSMS方法,这两个方法也是都加上了synchronized锁,sendEmail方法里面照样是延迟了1秒。

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Phone phone = new Phone();
    Phone phone2 = new Phone();
    new Thread(phone::sendEmail, "thread-a").start();
    // 为了让a线程率先启动
    Thread.sleep(100);
    new Thread(phone2::sendSMS, "thread-b").start();
}

最终得到的结果是sendSMS方法输出的内容。

java 复制代码
发送短信
发送邮件

分析:

这种情况是使用了两个不同的对象,也就是说这是两把不同的锁,访问的资源是各自的,所以不会出现等待的情况。

5 一个对象两个静态同步方法

这回改变以下同步方法,将其设置为静态方法。

java 复制代码
public static synchronized void sendEmail() {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    System.out.println("发送邮件");
}

public static synchronized void sendSMS() {
    System.out.println("发送短信");
}

# 执行方法:
public static void main(String[] args) throws InterruptedException {
    Phone phone = new Phone();
//        Phone phone2 = new Phone();
    new Thread(()  -> {
        phone.sendEmail();
    }, "thread-a").start();
    // 为了让a线程率先启动
    Thread.sleep(100);
    new Thread(()  -> {
        phone.sendSMS();
    }, "thread-b").start();
}

通过执行后,我们能够观察到thread-b要等thread-a执行完后才会去执行。

java 复制代码
发送邮件
发送短信

分析:

这种情况采用的是同一个对象,调用不同的方法,方法都是静态同步方法。然而会发生相互等待,那如果是两个不同对象呢?情况也还是会发生等待,这个在第六种情况会进行介绍。

6 两个对象两个静态同步方法

这次还是和上个代码一样,只是测试的时候,采用两个对象来执行各自方法。

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Phone phone = new Phone();
    Phone phone2 = new Phone();
    new Thread(()  -> {
        phone.sendEmail();
    }, "thread-a").start();
    // 为了让a线程率先启动
    Thread.sleep(100);
    new Thread(()  -> {
        phone2.sendSMS();
    }, "thread-b").start();
}

运行后,我们发现,phone2执行sendSMS也是需要等待phone执行sendEmail方法。

分析:

5、6两种情况都会发生等待,而这为什么呢?我想这就要我们了解static synchronized和synchronized两种方法是截然不同的。因为虽然通过new Phone出来的对象是两个不同的对象,但是在底层他们则是相同的模板,对于静态同步方法来说,锁的是模板。也就是说,5、6两种情况,他是把类锁。

5、6结论:

对于普通同步方法,锁的是当前实例对象,通常是this,也就是所有普通同步方法都是同一把锁,即对象本身,是把对象锁。

对于静态同步方法,锁的是当前类的Class对象,即是把类锁。

对于同步代码块,锁住的是synchronized括号里面的对象。

7 一个对象一个静态同步方法一个同步方法

接下来就是一个对象,一个静态同步方法(sendEmail)和一个同步方法(sendSMS)。通过一个对象调用两个方法。

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Phone phone = new Phone();
//        Phone phone2 = new Phone();
    new Thread(()  -> {
        phone.sendEmail();
    }, "thread-a").start();
    // 为了让a线程率先启动
    Thread.sleep(100);
    new Thread(()  -> {
        phone.sendSMS();
    }, "thread-b").start();
}

通过运行,我们发现,这回有所不一样,先输出了sendSMS的打印信息,也就是说thread-b线程不用等待thread-a执行完毕后才执行。

java 复制代码
发送短信
发送邮件

分析:

两种锁,一个是类锁,一个是对象锁,两个加锁的类型不同,不产生资源竞争。

8 两个对象一个静态同步方法一个同步方法

接下来就是两个对象,一个静态同步方法(sendEmail)和一个同步方法(sendSMS)。phone对象调用sendEmail方法,phone2调用sendSMS方法。

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Phone phone = new Phone();
    Phone phone2 = new Phone();
    new Thread(()  -> {
        phone.sendEmail();
    }, "thread-a").start();
    // 为了让a线程率先启动
    Thread.sleep(100);
    new Thread(()  -> {
        phone2.sendSMS();
    }, "thread-b").start();
}

运行的结果还是先输出了sendSMS的打印信息,也就是说thread-b线程不用等待thread-a执行完毕后才执行。

java 复制代码
发送短信
发送邮件

分析:

这种情况和第7种类似,只不过这次是使用了另一个对象的对象锁,与类锁还是不产生资源竞争。

总结:

所有的普通同步方法用的都是同一把锁一实例对象本身,就是new出来的县体实例对象本身,本类this,也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其他普通同步方法必须等待获取锁的方法释放锁后才能获取锁。

所有的静态同步方法用的也是同一把锁-类对象本身,就是我们说过的唯一板class,县体实例对象this和唯一模板class,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竟态条件的,但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁。

补:函数接口集合:

这里补充一下上小节对于CompletableFuture的学习的一个基础内容,想了想还是觉得有必要去了解一下关于函数接口的一些内容。

在Java中,函数接口(Functional Interface)是一种特殊的接口,它只有一个抽象方法。这种接口的主要目的是用于支持Lambda表达式和函数式编程。Java 提供了一些内置的函数接口,如FunctionPredicateConsumer等,同时也允许用户自定义函数接口。

以下是Java中一些常见的函数接口:

  1. Function<T, R>:接受一个参数并产生一个结果。它有一个抽象方法apply(T t),返回类型为R。
  2. Predicate<T>:接受一个参数并返回一个布尔值。它有一个抽象方法test(T t),返回类型为boolean。
  3. Consumer<T>:接受一个参数并执行某种操作,但不返回任何结果。它有一个抽象方法accept(T t),返回类型为void。
  4. Supplier<T>:不接受任何参数,但生成一个结果。它有一个抽象方法get(),返回类型为T。
  5. UnaryOperator<T>:接受一个参数并返回一个与输入类型相同的结果。它有一个抽象方法apply(T t),返回类型为T。
  6. BinaryOperator<T>:接受两个参数并返回一个与输入类型相同的结果。它有一个抽象方法apply(T t1, T t2),返回类型为T。
  7. BiFunction<T, U, R>:接受两个参数并产生一个结果。它有一个抽象方法apply(T t, U u),返回类型为R。
  8. BiPredicate<T, U>:接受两个参数并返回一个布尔值。它有一个抽象方法test(T t, U u),返回类型为boolean。
  9. BiConsumer<T, U>:接受两个参数并执行某种操作,但不返回任何结果。它有一个抽象方法accept(T t, U u),返回类型为void。
  10. IntConsumer:接受一个int类型的参数并执行某种操作,但不返回任何结果。它有一个抽象方法accept(int value),返回类型为void。
  11. LongConsumer:接受一个long类型的参数并执行某种操作,但不返回任何结果。它有一个抽象方法accept(long value),返回类型为void。
  12. DoubleConsumer:接受一个double类型的参数并执行某种操作,但不返回任何结果。它有一个抽象方法accept(double value),返回类型为void。

这些函数接口可以用于创建Lambda表达式,简化代码并提高可读性。例如,使用Function<Integer, Integer>接口可以将一个整数映射到另一个整数,如下所示:

java 复制代码
Function<Integer, Integer> square = x -> x * x;
int result = square.apply(5); // result 为 25

总之,Java中的函数接口是一种强大的工具,可以帮助我们编写更简洁、更易读的代码。

总结

锁篇文章是关于共享资源访问控制的一种机制。当一个线程试图访问共享数据时,它需要先获取到锁,然后才能进行操作。如果锁已经被其他线程持有,那么试图获取锁的线程将被阻塞,直到锁被释放。本篇文章介绍了一些关于多线程与锁的部分知识点,了解了什么是悲观锁和乐观锁。重点是掌握synchronized加锁的一些需要真正认识的知识。也补充了上章没有介绍完全的内容-关于函数接口的集合。


转发请携带作者信息 @怒放吧德德 @一个有梦有戏的人

持续创作很不容易,作者将以尽可能的详细把所学知识分享各位开发者,一起进步一起学习。

👍创作不易,如有错误请指正,感谢观看!记得点赞哦!👍

谢谢支持!

相关推荐
Tony Bai33 分钟前
【Go开发者的数据库设计之道】05 落地篇:Go 语言四种数据访问方案深度对比
开发语言·数据库·后端·golang
eqwaak01 小时前
Flask实战指南:从基础到高阶的完整开发流程
开发语言·后端·python·学习·flask
麻雀20251 小时前
一键面试prompt
面试·职场和发展·prompt
海琴烟Sunshine1 小时前
Leetcode 26. 删除有序数组中的重复项
java·算法·leetcode
RoboWizard1 小时前
移动固态硬盘连接手机无法读取是什么原因?
java·spring·智能手机·电脑·金士顿
PAK向日葵1 小时前
【算法导论】NMWQ 0913笔试题
算法·面试
PAK向日葵1 小时前
【算法导论】DJ 0830笔试题题解
算法·面试
PAK向日葵1 小时前
【算法导论】LXHY 0830 笔试题题解
算法·面试
笨蛋不要掉眼泪2 小时前
SpringBoot项目Excel成绩录入功能详解:从文件上传到数据入库的全流程解析
java·vue.js·spring boot·后端·spring·excel
wshzrf2 小时前
【Java系列课程·Java学前须知】第3课 JDK,JVM,JRE的区别和优缺
java·开发语言·jvm