Java并发编程纪实-深入CPU调度,线程同步到线程协作的艰辛之路


作为现代应用程序的核心基石,多线程技术为实现高并发性能提供了强大的支撑。但若不经大脑,它也可能成为一把"双刃剑"。本文将揭开CPU调度、线程同步和线程协作的神秘面纱,探寻并发编程背后的实现原理和精髓,为你编写高质量、高可靠的多线程代码指明方向。让我们一同走进线程的世界,体会它们在CPU核心上的艰难角逐,以及通过同步和协作取得最终胜利的传奇篇章!


内容概括:

  1. CPU核心数与线程数的关系剖析

  2. 操作系统时间片轮转调度机制解读

  3. 线程同步的多种实现方式及原理分析

  4. 线程协作的关键机制及应用场景剖析

  5. 通过源码和实例全面解析各同步和协作机制

  6. 多线程开发中常见的陷阱和注意事项


想要成为一名优秀的Java开发者,熟练掌握多线程编程是必修的基础功课。虽然线程的概念看似简单,但要真正将它们运用自如,需要对CPU、操作系统和JVM的工作原理有一个全面的认识和理解。让我们一起踏上这条探索之旅,领略多线程背后的精彩纷呈!


一、CPU核心数与线程调度


要理解多线程编程,我们首先要了解CPU和线程的关系。

在单核心CPU时代,操作系统通过时间片轮转算法来切换线程执行,让用户看上去像是多个线程在同时运行。但实际上,任何时候只有一个线程在真正运行。

现代计算机大多都是多核CPU,每个核心都可以同时执行一个线程,这就是真正的物理并行。因此,为了最大限度地利用多核CPU,我们往往需要创建线程数 >= CPU核心数,甚至更多。

但是过度创建线程也会导致上下文切换开销加大,反而使程序运行效率降低。具体线程数需要根据实际工作负载进行压力测试和调优。

// 获取当前机器CPU核心数  
int cpuCores = Runtime.getRuntime().availableProcessors();

二、线程同步的实现


多线程带来的好处是提高系统吞吐量,但坏处是可能导致线程安全问题。比如多个线程同时读写同一个资源时,可能会出现"脏读"。为了避免这种情况,我们需要对多线程代码进行同步。

1、synchronized

实现线程同步最直接的方式是使用synchronized关键字:

public synchronized void incrementCounter() {
    counter++;
}

synchronized 是 Java 中用于实现线程同步的一个关键字,它可以确保同一时刻只有一个线程能够访问被 synchronized 修饰的代码块或者方法。


(1)、synchronized的工作原理

以下是 synchronized 的工作原理的详细说明:

  • 锁的概念synchronized 通过使用内部锁(也称为监视器锁或对象锁)来实现同步。每个 Java 对象都有一个内部锁,当一个线程想要进入一个同步方法或代码块时,它必须首先获得该对象的锁。
  • 进入同步代码:当线程尝试进入一个 synchronized 方法或代码块时,它会尝试获得方法或代码块所在对象的锁。如果锁是可用的(即没有其他线程持有该锁),那么线程会成功获得锁并进入同步代码。
  • 锁的持有:一旦线程获得了锁,它就可以执行同步代码。在执行过程中,其他尝试进入同一个锁的线程会被阻塞,直到锁被释放。
  • 锁的释放:当线程执行完 synchronized 代码块或方法后,它会释放锁。这允许其他等待的线程尝试获得锁并进入同步代码。
  • 重入:如果一个线程已经持有某个对象的锁,它可以多次进入该对象的 synchronized 方法或代码块而不会被阻塞,因为锁是由同一个线程持有的。
  • 锁的可见性synchronized 还确保了内存的可见性。当线程释放锁之前,它必须将工作内存中的更改刷新到主内存中。这意味着其他线程在获得锁后,可以看到最新的共享变量值。
  • 锁的粒度synchronized 可以应用于方法或代码块。当应用于方法时,整个方法都是同步的;当应用于代码块时,只有代码块内的代码是同步的,这提供了更细粒度的控制。
  • 锁的竞争:在高并发的环境下,多个线程可能会同时尝试获得同一个锁,这可能导致锁竞争,从而影响程序的性能。
  • 死锁:不当使用 synchronized 可能导致死锁。例如,两个线程分别持有两个对象的锁,并尝试获取对方的锁,如果它们同时这样做,就会发生死锁。
  • 性能考虑:虽然 synchronized 提供了一种简单的方式来实现线程安全,但它也可能成为性能瓶颈,特别是在竞争激烈的情况下。因此,开发者需要仔细设计同步策略,以避免不必要的性能损耗。
  • 替代方案:Java 并发库提供了其他一些同步机制,如 ReentrantLockSemaphoreCountDownLatch 等,它们提供了更灵活的同步控制,有时可以作为 synchronized 的替代方案。

(2)、synchronized 使用场景

  • 当需要保证一个方法或者代码块在同一时刻只被一个线程访问时。

  • 当需要实现简单的互斥锁时。


(3)、synchronized的最佳实践

  • 尽量缩小同步代码块的范围,以减少线程阻塞的时间。
  • 避免在同步代码块中进行长时间的操作或调用远程服务,以避免不必要的等待。
  • 使用 synchronized 方法来同步实例方法,或使用 synchronized 代码块来同步对特定对象的访问。

2、ReentrantLock


ReentrantLock 是一个可重入的互斥锁,与 synchronized 类似,但它提供了更多的灵活性,如尝试非阻塞获取锁、可中断的锁获取操作、超时获取锁等。


(1)、示例代码

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final Lock lock = new ReentrantLock();

    public void performAction() {
        lock.lock();  // 获取锁
        try {
            // 受保护的代码
            System.out.println("Performing action...");
        } finally {
            lock.unlock();  // 释放锁
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample example = new ReentrantLockExample();
        Thread thread1 = new Thread(() -> example.performAction());
        Thread thread2 = new Thread(() -> example.performAction());
        
        thread1.start();
        thread2.start();
    }
}

(2)、使用场景

  • 当需要更灵活的锁操作,如尝试非阻塞获取锁、可中断的锁获取操作、超时获取锁等。

  • 当需要实现公平锁(即按照线程请求锁的顺序来获取锁)。


(3)、最佳实践

  • 总是使用 finally 块来释放锁,以避免死锁。
  • 考虑使用 tryLock() 方法来避免阻塞,特别是在高并发场景下。
  • 使用 lock()unlock() 方法时,确保在所有可能的执行路径中都能释放锁。

3、Semaphore

Semaphore 是一个计数信号量,用来控制同时访问某个特定资源的线程数量。它通过一个整数来控制线程的访问,初始值通常为可用资源的数量。


(1)、示例代码

import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    private final Semaphore semaphore = new Semaphore(3);  // 允许3个线程同时访问

    public void performAction() {
        try {
            semaphore.acquire();  // 获取一个许可
            try {
                // 受保护的代码
                System.out.println("Performing action with semaphore...");
            } finally {
                semaphore.release();  // 释放一个许可
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) {
        SemaphoreExample example = new SemaphoreExample();
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> example.performAction());
            thread.start();
        }
    }
}

(2)、使用场景

  • 当需要控制对某一组资源的并发访问数量时。
  • 当需要实现资源池时。

(3)、最佳实践

  • 根据可用资源的数量初始化 Semaphore

  • 使用 acquire() 方法来获取许可,并在操作完成后使用 release() 方法释放许可。

  • 确保在所有可能的执行路径中都能释放许可,以避免资源耗尽。


4、CountDownLatch

CountDownLatch 是一个同步辅助工具,允许一个或多个线程等待一组操作在其他线程中完成。它通过一个计数器来工作,每次调用 countDown() 方法时计数器减一,当计数器达到0时,所有等待的线程都会被唤醒。

(1)、示例代码

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    private final CountDownLatch latch = new CountDownLatch(3);

    public void performAction() {
        try {
            latch.await();  // 等待计数器达到0
            // 所有线程都完成了它们的任务
            System.out.println("All tasks completed.");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public void doTask() {
        try {
            // 执行一些任务
            System.out.println("Task is running...");
            Thread.sleep(1000);  // 模拟任务执行时间
            latch.countDown();  // 任务完成,计数器减一
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) {
        CountDownLatchExample example = new CountDownLatchExample();
        
        // 启动3个线程执行任务
        for (int i = 0; i < 3; i++) {
            Thread thread = new Thread(() -> example.doTask());
            thread.start();
        }
        
        // 主线程等待所有任务完成
        example.performAction();
    }
}

(2)、使用场景

  • 当一个或多个线程需要等待其他线程完成操作后才能继续执行时。

  • 当需要在某些线程完成初始化任务后启动主线程时。


(3)、最佳实践

  • 初始化 CountDownLatch 的计数器时,设置为需要等待的操作数量。

  • 在每个需要等待的操作完成后调用 countDown() 方法。

  • 使用 await() 方法来等待计数器达到0,确保所有操作都已完成。


三、线程协作


有时候,我们不仅需要让多线程同步访问资源,还需要它们之间互相通信和协作,以完成复杂的任务。Java提供了几种关键机制来实现线程间的协作:

在Java中,wait()notify()notifyAll() 是Object类中的三个方法,它们允许线程之间进行协作,以实现低级别的线程同步。这些方法通常与 synchronized 关键字一起使用。

1、wait() 方法

  • 当线程调用 wait() 时,它会释放当前对象的锁,并进入该对象的等待池(wait set)。
  • 线程会一直等待,直到另一个线程调用该对象的 notify()notifyAll() 方法,并重新获得该对象的锁。

2、notify() 方法

  • 当线程调用 notify() 时,它会随机唤醒该对象等待池中的一个等待线程。
  • 被唤醒的线程将从等待池中移动到锁池(entry set),并等待获得对象的锁。

3、notifyAll() 方法

  • 当线程调用 notifyAll() 时,它会唤醒该对象等待池中的所有等待线程。

  • 所有被唤醒的线程将从等待池中移动到锁池,并等待获得对象的锁。


4、最佳实践

  • 总是在 synchronized 块中调用 wait()notify()notifyAll() 方法。
  • 在调用 wait() 之前,确保线程已经获得了对象的锁。
  • 在调用 notify()notifyAll() 之后,通常需要释放锁,以便被唤醒的线程能够继续执行。
  • 使用 while 循环而不是 if 语句来检查等待条件,因为线程可能会因为伪唤醒(spurious wakeups)而提前醒来。

5、示例代码

以下是一个使用 wait()notify()notifyAll() 的简单示例:

public class ThreadCommunication {
    private boolean condition = false;

    public synchronized void waitForCondition() throws InterruptedException {
        // 等待条件变为 true
        while (!condition) {
            wait();
        }
        System.out.println("Condition is true, proceeding...");
    }

    public synchronized void setCondition() {
        // 更改条件并通知等待的线程
        condition = true;
        notifyAll();
    }

    public void changeCondition() {
        // 更改条件并通知等待的线程
        condition = false;
        System.out.println("Condition is changed to false.");
    }

    public static void main(String[] args) {
        ThreadCommunication communication = new ThreadCommunication();

        Thread waitingThread = new Thread(() -> {
            try {
                communication.waitForCondition();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread notifyingThread = new Thread(() -> {
            communication.setCondition();
        });

        Thread changingThread = new Thread(() -> {
            communication.changeCondition();
        });

        waitingThread.start();
        notifyingThread.start();
        changingThread.start();
    }
}

在这个示例中,waitForCondition() 方法中的线程将等待直到 condition 变为 truesetCondition() 方法将 condition 设置为 true 并调用 notifyAll() 来唤醒所有等待的线程。changeCondition() 方法将 condition 设置回 false

请注意,这个示例中的 waitForCondition() 方法使用了 while 循环而不是 if 语句,以确保即使线程被伪唤醒,它也会检查条件是否满足。这是使用 wait() 方法时的一个常见最佳实践。


四、多线程开发中常见的陷阱和注意事项


在Java多线程开发中,由于涉及操作系统底层调度、JVM内存模型、锁优化等诸多复杂因素,很容易产生一些陷阱和潜在风险。这些风险如果处理不当,会导致程序出现各种难以预料的并发问题,比如死锁、活锁、线程安全性问题等。作为开发者,我们必须时刻保持警惕,并掌握必要的规避措施。


以下是一些Java多线程开发中常见的陷阱和注意事项:

1. 脏读问题

脏读指的是某个线程读取到其他线程未完成的写入操作所导致的数据污染问题。这通常是由于变量没有正确同步引起的。比如:

private int count = 0;

public int incr() {
    return count++; // 非原子操作
}

上面的incr()方法是非线程安全的,因为它包含"读-改-写"三个步骤,如果多个线程同时执行,就会导致一些线程读取到"脏"数据。

解决方式是使用同步机制,如synchronized、Lock或者使用线程安全的原子类如AtomicInteger等。


2. 初始化未被正确同步

对象或者静态变量的初始化过程并不是原子操作,如果多个线程同时访问该实例,很容易读取到未完全初始化的状态。示例:

public class UnsafeLazyInitialization {
    private static UnsafeLazyInitialization instance;

    public static UnsafeLazyInitialization getInstance() {
        if (instance == null) {
             // 问题1: 两个线程同时进入初始化阶段
             instance = new UnsafeLazyInitialization();
             // 问题2: 构造函数未执行完,instance只是局部构建了一部分
        }
        return instance;
    }
}

解决方式有使用双重检查加锁、静态内部类等concurrent idiom模式。


3. 死锁

死锁是并发编程中最常见的一种多线程阻塞场景。当两个线程互相占有对方需要的资源,并且都在等待对方释放资源时,就会发生死锁。

private Object lock1 = new Object();
private Object lock2 = new Object();

public void instance1() {
    synchronized (lock1) {
        synchronized (lock2) {
            // ...
        }
    }
}

public void instance2() {
    synchronized (lock2) {  
        synchronized (lock1) {
            // ...
        }
    }
}

上面示例中,如果线程1获取了lock1又试图获取lock2,而线程2获取了lock2又试图获取lock1,就会发生死锁。

避免死锁的常见建议有:尽量保持获取锁的规则化;占用多个资源时,要采用一致的加锁顺序;加锁时限时等待而不是一直等待下去等。


4. 活锁和饥饿

活锁类似于死锁,但不同之处在于线程在没有获取到资源时,不会被阻塞,而是主动释放资源,然后重新请求。如果两个线程都这样做,就会出现活锁现象,它们永远无法获取想要的资源。

另一个常见问题是饥饿,即某个线程因为无法获取资源,而永远无法执行。通常发生在某种资源分配不合理的情况下。

针对这两类问题,我们应该合理分配线程优先级、避免线程被无限制延迟、以及在合理情况下退出无效的请求等。


5. 内存可见性和指令重排序

Java内存模型中存在可见性和指令重排序问题,这些问题都可能导致多线程下的执行结果出现意料之外的情况。比如:

private boolean ready = false;
private int result = 0; 

private void writer() {
    result = 1;  // 1
    ready = true; // 2  
}

private int reader() {    
    if (ready) { // 3
        return result; // 4
    } else {
        return 0;        
    }
}

上面这段代码看似很简单,但是由于JVM的指令重排序,它可能产生出result = 0这种意外结果。根本原因在于ready变量在多线程环境下可见性没有得到保证。

解决方法是通过volatile、锁、final等语义来解决可见性和有序性问题。


6. 不当使用线程局部变量(ThreadLocal)

ThreadLocal看似是个很美好的设计,可以让每个线程拥有自己单独的实例副本。但如果使用不当,很容易导致内存泄漏问题。因为ThreadLocal的核心实现依赖于每个线程的一个线程局部Map实例,这个Map实例的生命周期比线程要长。如果手动删除Map中的key,它就会一直保留value值,而value通常是我们手动创建的实例对象。

因此,我们在使用ThreadLocal时,要手动调用remove方法清理不再需要的value值,防止内存泄漏。


7. 不恰当的同步策略

有时候开发者为了"保险"起见,在不需要的地方也使用同步,这样不仅效率低下,还可能导致死锁等并发问题。

另一方面,如果没有充分理解volatile和synchronized等同步语义,使用时也可能产生意料之外的结果。

总的来说,在开发并发程序时,需要透彻理解同步机制的语义、可见性规则等,既要保证线程安全,又要合理利用资源、提高性能。这是一个需要不断学习和实践的过程。

8. 测试和调试的困难

与单线程程序不同,并发程序的执行往往受很多因素影响,比如CPU核心数、线程调度策略、线程优先级等。同一段代码多次运行,结果可能也会不同。这给并发程序的测试和调试带来了很大困难。

我们需要掌握一些常用的并发测试工具和框架,比如JMH、TestNG以及一些线程工具类(CountDownLatch等)。同时还要善于捕获程序执行时的现场信息,比如使用日志、System.out等记录关键时间点的信息。在出现并发问题时,需要结合这些信息进行分析和反推。

9. 并发程序设计的复杂性

相比于顺序执行的单线程程序,并发程序的设计、编码和维护都要复杂得多。我们需要考虑各种可能的交叉执行情况,防止并发问题。

并发程序设计的复杂性主要来自于多线程交叉执行时的不确定性和可能导致的各种并发问题,例如上文提到的死锁、活锁、内存可见性等。我们需要对线程之间的互相影响、资源共享和通信等进行细致入微的分析和把控。

此外,并发程序本身的执行过程往往也更加复杂和难以预测,需要高度的抽象思维能力来设计出健壮的并发模型和控制流程。


幸运的是,通过以下一些实践和经验积累,我们可以有效降低并发程序设计的复杂度:

  • 面向对象设计原则

    坚持单一职责、开闭、里氏替换等面向对象设计原则,有助于编写可维护、可扩展、可测试的并发代码。它们使代码模块化,降低了各模块之间的耦合度,从而减少了并发错误引入的可能性。

  • 并发设计模式

    像单例、生产者-消费者、读写锁、Future模式、双检锁等并发设计模式,为我们解决了很多常见的并发场景,无需重复"reinventing the wheel"。学习和掌握这些设计模式,能极大简化并发程序的设计和实现。

  • 明确并发级别

    在设计之初,就要明确软件或模块所需的并发级别。有些场景只需线程安全,有些则需要完全无锁和非阻塞的实现。根据实际需求选择合适的并发控制手段,避免过度同步导致的низ效率问题。

  • 分解任务和职责

    将复杂的并发操作分解为一系列相对独立的任务和职责,并为每一个部分指定不同的执行单元(线程)。通过这种"分而治之"的方式,使整个并发流程更加清晰和可控。

  • 设计无状态对象

    无状态对象天生是线程安全的,因为它们不需要在执行时共享数据。尽可能设计无状态对象,或者使对象内部状态是不可变的,就能很大程度上避免并发访问的风险。

  • 使用线程安全的集合类

    不要手动编写线程安全的集合类,Java已经为我们提供了一系列高效且经过良好测试的并发集合类,比如ConcurrentHashMap、CopyOnWriteArrayList等。直接使用它们能极大降低编程难度和风险。

  • 编写可测试和可调试的并发代码

    编写测试用例覆盖各种并发场景,使用并发测试框架和工具,保证每个并发组件都经过彻底测试。同时,为并发代码添加必要的日志输出和监控,以便在出现问题时可以快速诊断和定位根源。


总之,正确认识并发程序设计的复杂性至关重要。通过遵循一些设计原则、使用成熟模式和工具、分解职责、编写可测试代码等措施,我们就能逐步降低并发编程的难度,提高代码的健壮性和可维护性。只有真正掌握了并发编程的精髓,我们才能在这个多核时代写出高效、可靠的应用程序。


结语:

本篇博文到这里就告一个段落,我们对线程同步和协作的各种手段做了深入的分析和探讨。希望通过本文,你能更好地理解并发编程的本质,为日后编写健壮、高效的多线程程序奠定扎实的基础。如果你在实践中还有任何疑问或心得,欢迎在评论区留言讨论。

  • 18
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

w风雨无阻w

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值