likes
comments
collection
share

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

作者站长头像
站长
· 阅读数 23
环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8

前言

如下是上期的内容大纲,同学们自己查缺补漏。

  • 继承 Thread 类:
    • 创建线程的一种基本方式。
  • 实现 Runnable 接口:
    • 创建线程的推荐方式,可以避免单继承限制。
  • 使用 Callable 和 Future:
    • 实现有返回值的线程任务。
  • 继承Thread类 VS 实现Runnable接口
  • 案例分析
  • 应用场景
  • 类代码方法介绍
  • 测试用例
  • 测试结果展示
  • 测试代码解析

  正如古人云:“温故而知新,可以为师矣!”,复习旧知识是学习新知识的重要步骤。如果你已经对上期内容了如指掌,那么恭喜你,你的自学能力非常强,这是成为一名优秀程序员的重要素质。

  所以,我们在学习了Java多线程的创建和管理之后,我们就基本已掌握了如何在程序中启动和调度线程。然而,多线程编程的真正挑战在于如何高效且安全地管理这些线程,特别是在多个线程需要访问共享资源时。这我就引入一个新的概念了【线程同步】——确保在并发环境下数据的一致性和完整性。从线程的创建到线程同步,我们不仅仅是在启动执行任务,更是在精心编排一场线程间的和谐交响曲。接下来,让我们深入了解Java提供的同步机制,学习如何用synchronized关键字、同步代码块以及强大的Locks和Conditions来确保我们的多线程应用既高效又稳定。

如下是本期重点内容大纲,请查收:

  • 同步方法:
    • 使用 synchronized 关键字修饰方法实现同步。
  • 同步代码块:
    • 使用 synchronized 关键字修饰代码块实现同步。
  • 使用 Locks 和 Conditions:
    • 使用 ReentrantLock、ReentrantReadWriteLock 实现更灵活的同步。

摘要

  在多线程编程中,确保共享数据的一致性和线程之间的协调是至关重要的,这也是本期的核心。我们可能或多或少都知道些,Java提供了多种同步机制来处理这些问题。顾本文将深入探讨围绕Java中的同步方法、同步代码块以及基于锁的更灵活的同步策略展开深入探索,我将会详细介绍Java中实现线程同步的三种主要方法:同步方法、同步代码块以及使用LocksConditions。通过实际代码示例和案例分析,本文旨在帮助大家能够理解并深入应用这些同步机制来保证多线程同步,这点也是我创该多线程专栏的初心,咱们接着往下看。

正文

简介

  首先我们先来搞清楚一个概念,何为多线程同步?这个问题,见名知意,多线程同步是指在多线程环境中,通过特定的技术手段确保共享资源在同一时刻只被一个线程访问,目的就是保证线程安全。清楚概念后,我们继续学,既然Java自身就提供了多种线程同步机制来确保线程安全和数据一致性,比如这些机制包括使用synchronized关键字的同步方法和同步代码块,以及基于Lock接口的更灵活的同步控制,那你们都学了没?能正确使用吗?又如何正确应用去保证线程的同步?是不是被我问的有点懵,其实没关系啦,我就是想把你们教会,不会咱学就完啦!这不丢人,学完我的教程,绝对能够把多线程篇吃透。

  接下来,我们就重点来聊聊那几种同步机制。

同步方法

概述

  首先,同步机制方法之一,可以通过同步方法来实现,同步方法本身就是一种简单直接的同步手段。它通过在方法声明前加上synchronized关键字来确保一次只有一个线程能够执行该方法。这种方式的实现简单,理解起来也容易,尤其适合那些对并发编程不太熟悉或者需要快速实现同步的场景。

深入理解

  当一个方法被标记为synchronized时,它不仅保证了方法的原子性,还隐式地使用了当前实例对象作为锁。这意味着,如果多个线程尝试访问同一个对象的同步方法,它们将被序列化地执行,一个接一个地等待前一个线程执行完毕。

示例演示

  当一个实例方法被声明为synchronized时,Java会自动在方法执行期间对当前对象加锁。

package com.secf.service.port.hpy.day3;

/**
 * @Author bug菌
 * @Source 公众号:猿圈奇妙屋
 * @Date 2024-06-25 21:11
 */
public class BankAccount {
    private double balance;

    public synchronized void deposit(double amount) {
        balance += amount;
        System.out.println("Deposited: " + amount);
    }

    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
            System.out.println("Withdrew: " + amount);
        }
    }
}

  在上面的示例中,deposit和withdraw方法都是同步方法,它们保证了对balance字段的操作是原子性的,不会出现多个线程同时修改账户余额的情况。

代码解读

  接着我将对上述代码进行详细的一个逐句解读,希望能够帮助到同学们,能以更快的速度对其知识点掌握学习,这也是我写此文的初衷,授人以鱼不如授人以渔,只有将其原理摸透,日后应对场景使用,才能得心应手,所以如果有基础的同学,可以略过如下代码分析步骤,然而没基础的同学,还是需要加强对代码的理解,方便你深入理解并掌握其常规使用。

  这段代码定义了一个简单的BankAccount(银行账户)类,用于演示如何在Java中使用同步方法来保证线程安全。下面是对这段代码的详细分析:

类定义和成员变量

  • BankAccount类有一个私有的double类型的成员变量balance,用来存储账户的余额。

同步方法

  • deposit(double amount):这是一个同步方法,它接受一个double类型的参数amount,然后将这个数值加到balance上,表示向账户中存入了一定的金额。之后,它打印一条消息,显示存入的金额。

  • withdraw(double amount):这也是一个同步方法,它接受一个double类型的参数amount,表示想要从账户中取出的金额。如果账户余额balance大于或等于要取出的金额,它将从balance中减去这个数值,表示取款操作成功,然后打印一条消息,显示取出的金额。

线程安全性

  • 由于depositwithdraw方法都被声明为synchronized,这意味着这些方法在任意时刻只能由一个线程执行。这保证了对balance变量的操作是原子性的,防止了多个线程同时修改balance可能导致的数据不一致问题。

示例使用

使用示例

  接着我就写个main函数使用BankAccount类,示例代码如下:

    public static void main(String[] args) {
        BankAccount account = new BankAccount();

        // 启动线程进行存款操作
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                account.deposit(100.0);
            }
        });

        // 启动线程进行取款操作
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50; i++) {
                account.withdraw(50.0);
            }
        });

        t1.start();
        t2.start();
    }

结果展示

  根据如上的代码用例,作者在本地进行测试结果如下,仅供参考,你们也可以自行修改测试用例或者添加其他的测试数据或测试方法,以便于进行熟练学习以此加深知识点的理解。

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

注意事项

  • 虽然使用synchronized可以保证线程安全,但它也可能成为性能瓶颈,尤其是在高并发场景下。因此,开发者需要仔细考虑同步的范围和粒度。
  • 在设计多线程程序时,应尽量避免长时间的同步操作,以免造成线程阻塞。
  • 除了synchronized方法,还可以使用同步代码块来提供更细粒度的控制,仅对需要同步的代码段进行同步。(这点也是我下一个要将的知识点,这里就que一下。)

扩展思考

  • 其实在实际应用中,可能需要更复杂的同步策略,例如使用ReentrantLock或其他并发工具来实现更高级的同步机制。
  • 还可以考虑使用AtomicDouble等原子类来实现无锁的线程安全编程,这在某些场景下可以提供更好的性能。

  (如上两点,其实也是为了埋下伏笔!懂得都懂)

同步方法的局限性

  尽管同步方法易于实现,但它也有其局限性。最主要的问题在于它可能导致性能瓶颈,因为每个线程在执行同步方法时都需要等待获取锁。此外,如果同步方法中包含了复杂的逻辑或者执行时间较长的操作,它可能会成为线程调度的瓶颈。

性能考虑

  在某些情况下,过度使用同步方法可能会导致程序的性能问题。因此,同学们需要仔细考虑何时以及如何使用同步方法。如果可能,尽量缩小同步代码块的范围,仅对需要同步的部分进行同步,而不是整个方法。

替代方案

  对于那些需要更细粒度控制的场景,Java还提供了同步代码块和Lock接口。同步代码块允许大家只对关键部分的代码进行同步,而Lock接口则提供了更高级的锁特性,如尝试获取锁、可中断的锁获取、超时等待等。(这些大家在日常使用中都需要考虑到位。)

结合现代并发工具

  随着Java并发库的发展,如java.util.concurrent包,开发者有了更多的选择来实现同步和线程安全。例如,ReentrantLockSemaphoreCountDownLatch等工具类提供了更灵活的线程协调机制。

最佳实践

  • 最小化同步范围:只对共享资源的访问进行同步,而不是整个方法。
  • 使用并发工具:考虑使用Java并发库中的高级工具,以满足更复杂的同步需求。
  • 性能测试:在实现同步后,进行性能测试,确保同步机制不会成为性能瓶颈。
  • 通过深入理解同步方法以及合理利用Java提供的其他同步工具,你们可以编写出既线程安全又高效的多线程程序。记住,同步是确保数据一致性的关键,但也需要谨慎使用,避免不必要的性能损失。

同步代码块

  同步代码块是Java并发编程中的一项重要特性,它提供了一种灵活的方式来同步线程对共享资源的访问。与同步方法相比,同步代码块允许开发者只对需要同步的代码片段进行加锁,而不是整个方法,这样可以减少不必要的同步开销,提高程序的性能,但也不是说任何场景都是优于同步方法。

示例演示

  在如下这个例子中,我们使用一个名为lock的私有final对象作为锁。increment方法中的代码块是同步的,只有获得lock的对象锁的线程才能执行这段代码。

示例代码

示例代码如下:

package com.secf.service.port.hpy.day3;

/**
 * @Author bug菌
 * @Source 公众号:猿圈奇妙屋
 * @Date 2024-06-25 21:11
 */
public class Counter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            count++; // 此处的代码是同步的
            System.out.println("Count after increment: " + count);
        }
    }
}

  然后我们再来写个main函数调用Counter类测试一波,验证控制台的输出结果最终是否count = 10,且可能的输出示例为:

Count after increment: 1
Count after increment: 2
...
Count after increment: 10

  实际的输出可能会有所不同,因为线程调度是由操作系统管理的,哪个线程先执行是不确定的。

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

        // 启动线程进行存款操作
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

        // 启动线程进行取款操作
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

        t1.start();
        t2.start();
    }

示例代码解析

  接着我将对上述代码进行详细的一个逐句解读,希望能够帮助到同学们,能以更快的速度对其知识点掌握学习,这也是我写此文的初衷,授人以鱼不如授人以渔,只有将其原理摸透,日后应对场景使用,才能得心应手,所以如果有基础的同学,可以略过如下代码分析步骤,然而没基础的同学,还是需要加强对代码的理解,方便你深入理解并掌握其常规使用。

  如上代码演示了如何使用同步代码块来确保对共享资源(在这个例子中是Counter类的count字段)的线程安全访问。下面是对代码执行结果的分析:

  1. 类定义

    • Counter类有一个私有的整型字段count,用于存储计数值,并初始化为0。
    • lock对象被声明为final,这意味着它在初始化后不能被重新赋值,通常用作同步锁。
  2. 同步方法

    • increment方法是Counter类的一个公共方法,负责增加count的值。这个方法内部使用了一个同步代码块,该代码块以lock对象作为同步锁。
  3. 主方法

    • main方法中创建了一个Counter实例。
    • 创建了两个线程t1t2,它们都执行对Counter实例的increment方法的调用,每次循环调用5次。
  4. 线程启动

    • 两个线程t1t2都被启动,它们将并发地访问Counter实例的increment方法。
  5. 同步执行

    • 当一个线程执行increment方法并进入同步代码块时,它会获得lock对象的锁。在此期间,其他线程如果尝试进入同一个同步代码块,将会被阻塞,直到锁被释放。
  6. 输出结果

    • 每次increment方法成功执行后,都会打印出更新后的count值。
  7. 预期输出

    • 程序的输出将显示count值依次增加,但由于线程间的并发执行,实际的打印顺序和次数可能会有所不同。不过,最终的count值应该是10(每个线程增加5次)。
  8. 线程安全

    • 由于使用了同步代码块,count变量的更新是线程安全的,不会出现由于并发访问导致的数据不一致问题。
  9. 程序结束

    • 一旦两个线程都完成了它们的任务,主线程将继续执行,并最终程序将结束。

注意事项:

  • 尽管使用了同步代码块,但程序的性能可能会受到线程竞争的影响,特别是在高并发的情况下。
  • 如果increment方法中有更多复杂的逻辑,可能会导致线程在锁上花费更多的等待时间,影响性能。
  • 在设计多线程程序时,应仔细考虑同步的范围和锁的选择,以确保既有足够的线程安全,又能保持合理的性能。

示例代码运行结果

  根据如上的测试用例,作者在本地进行测试结果如下,仅供参考,你们也可以自行修改测试用例或者添加其他的测试数据或测试方法,以便于进行熟练学习以此加深知识点的理解。

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

  大家可以发现,最后count值是到了10!如果increment方法中的同步代码块不加synchronized关键字,那么这段代码将失去线程同步的保护作用。在这种情况下,多个线程可能会同时执行increment方法中的代码,那么输出可能的示例:

Count after increment: 1
Count after increment: 2
...
Count after increment: 3  // 可能的输出,但不是10

  这里实际输出的确切数字取决于线程的调度和执行时机。

  所所以说,若是没有同步保护,多个线程可能会同时读取并修改count变量,这将导致竞态条件。最后count原子性被破坏,导致count++操作不是一个原子操作。在没有同步的情况下,这个操作可能会被中断,导致线程安全问题。

  我们也可以本地试试,去掉synchronized关键字试试,验证一下是否跟我如上所言一致。

运行结果如下:

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

  如上结果是不是显而易见,count出现重复值!

优势

  1. 细粒度控制:大家可以精确控制哪些代码需要同步,而不是整个方法。
  2. 提高性能:减少同步的范围可以降低线程阻塞的可能性,从而提高程序的整体性能。
  3. 灵活性:可以在不同的方法中使用同一个锁对象,实现对共享资源的同步访问。

使用场景

同步代码块适用于以下场景:

  • 当多个方法或类需要访问同一资源,并且只有部分代码需要同步时。
  • 当需要在方法的某个特定条件下进行同步,而不是整个方法都需要同步时。

注意事项

  • 锁的选择:选择一个合适的锁对象是关键。通常,可以使用类的任意私有静态字段或方法中的新对象作为锁。
  • 避免死锁:在使用同步代码块时,要注意避免死锁的发生,确保锁的获取顺序一致。
  • 资源释放:在同步代码块中,确保及时释放资源,例如关闭文件流或数据库连接。

同步代码块 VS 同步方法

  • 同步方法:整个方法自动成为同步的,使用简单,但不够灵活。
  • 同步代码块:只同步必要的代码段,提供了更高的灵活性和性能,但需要手动管理锁。

小结

  同步代码块是Java多线程编程中实现细粒度锁控制的有效工具。通过合理使用同步代码块,使用者可以在保证线程安全的同时,优化程序的性能。然而,这也要求我们具备更高的并发编程技巧,以避免常见的并发问题,如死锁和资源竞争等,这些都是需要我们在使用多线程时务必需要关注的。

使用 Locks 和 Conditions

  对比synchronizedLocksConditions提供了比synchronized更复杂的线程同步能力。ReentrantLock是一个可重入的互斥锁,而ReentrantReadWriteLock允许多个读操作同时进行,但写操作是排他的。

  LocksConditions,它俩是Java并发API中的重要组成部分,它们提供了比传统synchronized关键字更为复杂和灵活的线程同步机制。这些工具类位于java.util.concurrent.locks包中,它们支持更细粒度的锁操作,有助于构建高效且易于管理的并发应用程序。

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

ReentrantLock - 可重入的互斥锁

  ReentrantLock是一种可重入的互斥锁,与synchronized相比,它不仅支持尝试非阻塞地获取锁,还允许中断锁的获取过程,提供了超时等待获取锁的能力。

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

使用ReentrantLock

  如下我给大家演示下如何使用ReentrantLock,仅供参考:

    public void testReentrantLock() {
        Lock lock = new ReentrantLock();

        lock.lock();  // 获取锁
        try {
            // 保护的代码
        } finally {
            lock.unlock();  // 确保释放锁
        }
    }

特点

  • 可重入性:同一线程可以多次获取同一把锁,每获取一次,锁的持有计数器就增加,只有当计数器减至零时,其他线程才有机会获取该锁。
  • 可中断性:线程在尝试获取锁的过程中可以响应中断。
  • 尝试获取tryLock()方法允许线程尝试获取锁,如果获取失败可以选择立即重试或稍后重试,而不是无限期地等待。

ReentrantReadWriteLock - 可重入的读写锁

  ReentrantReadWriteLock是一种允许多个读操作同时进行,但写操作是排他的锁。这种锁非常适合读多写少的场景,可以显著提高性能。

使用ReentrantReadWriteLock

public class TestReentrantReadWriteLock {
    public void testReentrantReadWriteLock() {
        ReadWriteLock rwLock = new ReentrantReadWriteLock();
        Lock readLock = rwLock.readLock();
        Lock writeLock = rwLock.writeLock();

        readLock.lock();  // 获取读锁
        try {
            // 执行读操作
        } finally {
            readLock.unlock();  // 释放读锁
        }

        writeLock.lock();  // 获取写锁
        try {
            // 执行写操作
        } finally {
            writeLock.unlock();  // 释放写锁
        }
    }
}

ReentrantReadWriteLock部分源码展示如下:

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

特点

  • 读写分离:多个线程可以同时读取,但写入时需要独占访问。
  • 重入性:与ReentrantLock类似,读写锁也支持重入。
  • 锁降级:从写锁降级到读锁是安全的,但不允许从读锁升级到写锁。

Conditions - 条件对象

Condition接口提供了更灵活的条件变量管理,类似于Object类的wait()notify()notifyAll()方法,但提供了更多的控制能力和灵活性。

使用Condition

public class TestCondition {
    public void testCondition() throws InterruptedException {
        Lock lock = new ReentrantLock();
        Condition condition = lock.newCondition();

        lock.lock();
        try {
            // 等待条件满足
            condition.await();
            // 条件满足后的代码
        } finally {
            lock.unlock();
        }

        // 在其他地方唤醒等待的线程
        lock.lock();
        try {
            condition.signal();  // 唤醒一个等待的线程
            // 或者
            condition.signalAll();  // 唤醒所有等待的线程
        } finally {
            lock.unlock();
        }
    }
}

newCondition部分源码展示如下:

线程安全的艺术:Java中的同步方法与实践 | 多线程篇(三)

特点

  • 条件等待:线程可以在某个条件不满足时挂起,并在条件满足时被唤醒。
  • 多条件支持:一个锁可以关联多个条件变量。

结论

  LocksConditions它两提供了比synchronized更丰富的线程同步机制。它们不仅支持更细粒度的控制,还提供了诸如尝试获取锁、可中断等待、超时获取锁等高级特性。这些工具类使得开发者能够更精确地管理并发,构建出既高效又可靠的多线程应用程序。然而,使用这些高级API也需要开发者具备深入的并发编程知识,以避免常见的陷阱,如死锁和资源泄露。

案例分析

比如:考虑一个银行账户转账的场景,需要确保在多个线程操作时,账户余额的正确性等等,这里用到的非常之多,你们有补充的也可以评论区告知我,一起分享。

应用场景案例列举

  1. 多线程数据累加器:使用同步方法或同步代码块来保证累加操作的原子性。
  2. 资源共享系统:使用ReentrantReadWriteLock来允许多个读操作,但写操作需要独占访问。

优缺点分析

  • 同步方法:简单易用,但可能降低灵活性。
  • 同步代码块:提供了更好的灵活性,允许只同步关键部分代码。
  • Locks和Conditions:提供了更高级的同步功能,如尝试非阻塞获取锁、可中断的锁获取等。

类代码方法介绍

  • synchronized关键字:用于实现同步方法或同步代码块。
  • Lock接口:定义了锁的基本操作。
  • ReentrantLock类:实现了Lock接口,提供了可重入的互斥锁。
  • ReentrantReadWriteLock类:允许多个读操作,但写操作是排他的。

全文小结

  本文我详细介绍了Java中的同步机制,包括同步方法、同步代码块以及使用LocksConditions。每种方法都有其适用场景和优缺点。大家在使用的过程中,应根据具体需求选择合适的同步策略,以确保程序的线程安全和性能。

总结

  掌握Java的同步机制对于编写正确的多线程程序至关重要。虽然使用synchronized关键字可以简化同步操作,但LocksConditions提供了更灵活和强大的同步能力。大家应根据具体需求选择合适的同步策略,以确保程序的线程安全和性能。合理利用多线程同步可以显著提升程序的响应速度和处理能力,但同时也要注意线程安全和资源管理,避免潜在的并发问题。

... ...

至此,感谢阅读本文,如果你觉得有所收获,不妨点赞、关注和收藏,以支持bug菌继续创作更多高质量的技术内容。同时,欢迎加入我的技术社区,一起学习和成长。   

学无止境,探索无界,期待在技术的道路上与你再次相遇。咱们下期拜拜~~

往期推荐

转载自:https://juejin.cn/post/7389064690125127732
评论
请登录