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

683 阅读21分钟
环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8

一、前言

  还是经典老配方,我们先来温故下上期《线程创建?继承Thread类 VS 实现Runnable接口 | 多线程篇(二)》,通过上期内容,我们共同学习了Java多线程的三种创建方式,包括但不限于线程的多种创建方式, 及方式对比等概念。我相信,通过那一期的学习,大家已经对多线程创建及使用场景有了初步的理解和掌握。

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

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

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

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

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

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

二、摘要

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

三、正文

1.方式1:同步方法

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

a.什么是同步方法?

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

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

例如如下示例演示:

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字段的操作是原子性的,不会出现多个线程同时修改账户余额的情况。

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

b.工作原理

Java对象的内存模型

  Java的内存模型(Java Memory Model, JMM)定义了多线程程序中变量的读取和写入行为。在JMM中,每个线程都有自己的工作内存(或称为线程本地存储),它保存了从主内存中拷贝的变量副本。线程在对变量进行操作时,先从工作内存中读取,然后执行操作,最后将结果写回主内存。

  当多个线程同时访问同一个共享变量时,可能会出现线程之间的数据不一致问题。为了保证变量的一致性,Java通过 synchronized关键字引入了锁机制,确保同一时刻只有一个线程能够访问某个共享资源。

锁的实现原理

  在Java中,每个对象都有一把隐含的锁(也称为监视器锁或内置锁)。当一个线程调用一个同步方法时,它必须先获得这个对象的锁。获得锁之后,线程才能进入同步方法并执行其内容。在同步方法执行完毕后,锁会被释放,其他等待的线程可以继续获取锁并执行相应的同步方法。

对象级锁与类级锁
  • 对象级锁:适用于实例方法。当一个线程调用一个对象的同步实例方法时,它会获得该对象的锁。其他线程在尝试调用同一个对象的同步方法时,必须等待锁的释放。不同对象的锁是相互独立的,因此可以并行执行不同对象的同步方法。
  • 类级锁:适用于静态方法。类级锁与类本身关联,而不是某个特定的对象。当一个线程调用一个类的同步静态方法时,它会获得该类的锁。其他线程在尝试调用同一个类的同步静态方法时,必须等待锁的释放。
public class LockExample {
    public synchronized void instanceMethod() {
        // 获取对象级锁
    }

    public static synchronized void staticMethod() {
        // 获取类级锁
    }
}
synchronized关键字的优化

  Java编译器和JVM对 synchronized关键字做了许多优化,以减少锁的开销,提高多线程程序的性能。以下是几种常见的优化技术:

偏向锁(Biased Locking)

  偏向锁是Java 6引入的一种锁优化机制。偏向锁的目的是减少同一线程多次获得锁的开销。如果一个线程获得了偏向锁,那么在没有其他线程竞争的情况下,偏向锁不会被释放,这样就可以减少锁的获取和释放操作的成本。

// 偏向锁的实现示例(无需显式编写代码,JVM自动优化)
public synchronized void method() {
    // 线程可以偏向锁执行
}
轻量级锁(Lightweight Locking)

  轻量级锁是Java 6引入的另一种锁优化机制。它通过使用CAS(Compare-And-Swap)操作来减少锁的获取和释放开销。如果没有线程竞争,锁会升级为轻量级锁。当发生竞争时,锁会进一步升级为重量级锁。

重量级锁(Heavyweight Locking)

  当线程竞争激烈时,锁会升级为重量级锁。在这种情况下,线程会进入阻塞状态,直到获得锁。重量级锁的性能开销较大,因此在设计多线程程序时,应尽量减少竞争,避免频繁的锁升级。

c.使用示例

  接着我就写个测试类使用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();
    }

d.结果展示

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

e.有何局限性?

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

2.方式2:同步代码块

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

a.示例演示

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

示例代码

示例代码如下:

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方法中有更多复杂的逻辑,可能会导致线程在锁上花费更多的等待时间,影响性能。
  • 在设计多线程程序时,应仔细考虑同步的范围和锁的选择,以确保既有足够的线程安全,又能保持合理的性能。
示例代码运行结果

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

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

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

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

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

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

运行结果如下:

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

b.使用场景

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

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

c.同步代码块 VS 同步方法

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

d.为什么选择同步代码块?

  1. 提高并发性:通过锁定特定的代码块,而不是整个方法,可以减少锁的持有时间,从而提高程序的并发性。
  2. 精确控制同步范围:同步代码块允许开发者精确控制需要同步的代码范围,仅对关键部分进行同步,从而减少不必要的开销。
  3. 支持多锁机制:使用同步代码块,开发者可以在一个类中使用多个不同的锁来保护不同的资源,从而避免锁的冲突和竞争。

e.实现原理

  同步代码块的实现基于Java的内置锁(监视器锁)。当一个线程进入同步代码块时,它会尝试获取锁对象的监视器锁。如果成功获取,线程就可以执行代码块中的操作,否则线程将被阻塞,直到锁对象的监视器锁被释放。

public class SynchronizedBlockExample {
    private final Object lock = new Object();

    public void method() {
        // 其他非同步代码

        synchronized (lock) {
            // 仅此块代码是同步的
            System.out.println("Thread " + Thread.currentThread().getName() + " is executing synchronized block.");
        }

        // 其他非同步代码
    }
}

  在这个示例中,只有同步代码块中的代码是同步的,其他部分的代码可以被多个线程并发执行,从而减少锁的持有时间,提升并发性。

3.方式3:使用 Locks 和 Conditions

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

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

a.ReentrantLock - 可重入的互斥锁

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

如何使用ReentrantLock

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

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

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

b.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部分源码展示如下:

c.Conditions - 条件对象

Condition接口类似于Object类中的wait()notify()方法,但它提供了更细粒度和更灵活的控制。通过Condition接口,开发者可以实现更复杂的线程间协作模式,如多条件的等待和通知。

Condition的典型用法包括以下几个步骤:

  1. 创建一个Condition对象,通常通过ReentrantLocknewCondition()方法。
  2. 在线程中调用Conditionawait()方法,使线程进入等待状态,直到特定条件满足。
  3. 当条件满足时,调用Conditionsignal()signalAll()方法,唤醒一个或多个等待的线程。
如何使用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部分源码展示如下:

Condition的高级特性
1. 可中断的等待

Condition接口提供了可中断的等待机制,当线程在等待条件时,如果接收到中断信号,线程可以响应中断并退出等待状态。这使得线程在长时间等待时更加灵活。

public void waitForConditionInterruptibly() throws InterruptedException {
    lock.lock();
    try {
        while (!ready) {
            condition.await();  // 这个方法可以被中断
        }
    } finally {
        lock.unlock();
    }
}

在这个示例中,await()方法可以响应线程的中断请求,从而使线程退出等待状态。

2. 超时等待

有时,线程需要在一定时间内等待条件,如果超时后条件仍未满足,线程需要继续执行其他任务。Condition接口提供了带有超时功能的await()方法:

public void waitForConditionWithTimeout() throws InterruptedException {
    lock.lock();
    try {
        boolean success = condition.await(5, TimeUnit.SECONDS);  // 等待5秒
        if (success) {
            System.out.println("Condition met within timeout.");
        } else {
            System.out.println("Timeout, condition not met.");
        }
    } finally {
        lock.unlock();
    }
}

在这个示例中,线程会等待5秒钟,如果条件在这段时间内被满足,线程继续执行;否则,线程将在超时后继续执行其他任务。

3. 多条件等待与通知

Condition允许一个锁对象拥有多个条件,这使得开发者可以更精细地控制线程的等待和唤醒过程。例如,生产者-消费者模型中,可以为生产者和消费者分别创建Condition对象,从而更灵活地管理它们之间的协作。

Condition与其他同步机制的对比
  • Objectwait()notify()对比Condition接口提供了比wait()notify()更细粒度的控制,支持多个条件变量、可中断等待和超时等待等高级功能。而wait()notify()则适合于简单的同步需求。
  • ReentrantLock对比Condition通常与ReentrantLock一起使用,提供比 synchronized更强大的功能,如可中断锁、条件变量等。Condition主要用于需要精确控制线程等待和唤醒的场景,而ReentrantLock则负责锁的获取和释放。

四、全文小结

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

五、总结

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

... ...

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

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

六、往期推荐

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!