Java 并发:掌握多线程的艺术

发布于:2024-08-13 ⋅ 阅读:(82) ⋅ 点赞:(0)

释放 Java 并发的强大功能,打造高效、可扩展的应用程序。通过并发编程最大程度地提高性能和响应能力。

释放多线程和并行计算的强大功能,通过 Java 并发性增强您的应用程序。无论是初学者还是专家,本指南都会将您的技能提升到快节奏的并发编程领域。系好安全带,让我们揭开 Java 强大的 API,在高性能编码领域开启一段令人着迷的旅程!

什么是并发?

所有顶级Java 开发公司都使用并发性,它指的是程序同时执行多个任务的能力。它可以高效利用系统资源,并可以提高应用程序的整体性能和响应能力。

Java 并发的概念、类和用于多线程的接口,例如 `Thread`、`Runnable`、`Callable`、`Future`、`ExecutorService` 和 `java.util.concurrent` 中的类,都是标准 Java 库的一部分,因此各种 Java 框架之间不应该有太大的差异但在深入讨论之前,首先要回答一个非常基本的问题。

Java 中有多个线程吗?

多线程是指在单个应用程序中存在多个执行线程的编程技术。

多线程只是 Java 中实现并发的一种方式。还可以通过其他方式实现并发,例如多处理、异步编程或事件驱动编程。

但对于初学者来说,“线程”是可由计算机处理器独立执行的单一进程流。

为什么要使用 Java 并发?

由于多种原因,并发是构建高性能现代应用程序的绝佳解决方案。

提高性能

并发可以将复杂且耗时的任务划分为可同时执行的较小部分,从而提高性能。这可以充分利用当今的多核 CPU,并可使应用程序运行得更快。

提高资源利用率

并发可以实现系统资源的最优化利用,从而提高资源效率。通过实现异步 I/O 操作,系统可以避免阻塞单个线程并允许其他任务并发运行,从而最大限度地提高资源利用率和系统效率。

提高响应能力

提高响应能力:并发性可以保证应用程序保持响应能力,从而增强交互式应用程序中的用户体验。在一个线程执行计算密集型任务期间,另一个线程可以同时处理用户输入或 UI 更新。

简化建模

在某些场景(例如模拟或游戏引擎)中,并发实体是问题域所固有的,因此并发编程方法更直观、性能更高。这通常称为简化建模。

强大的并发 API

Java 提供了全面且适应性强的并发 API,其中包括线程池、并发集合和原子变量,以确保稳健性。这些并发工具简化了并发代码的开发并缓解了普遍存在的并发问题。

并发缺点

重要的是要明白并发编程并不适合初学者。它会给你的应用程序带来更高的复杂程度,并带来一系列独特的困难,例如管理同步、防止死锁、保证线程安全等等。在深入研究之前,需要考虑以下几点。

复杂性:编写并发程序比编写单线程程序更困难且更耗时。开发人员必须掌握同步、内存可见性、原子操作和线程通信。

调试困难:并发程序的非确定性特性可能会给调试带来挑战。竞争条件或死锁的发生可能不一致,这对重现和解决它们带来了挑战。

错误可能性:并发处理不当可能导致竞争条件、死锁和线程干扰等错误。此问题可能难以识别和解决。

资源争用:设计不良的并发应用程序可能会引起资源争用,即许多线程争夺同一资源,从而导致性能损失。

开销:创建和维护线程会增加机器的 CPU 和内存使用量。管理不善可能会导致性能不佳或资源耗尽。

测试复杂:由于线程执行不可预测且不确定,因此测试并发程序可能具有挑战性。

因此,虽然并发是一个很好的选择,但它并不是一帆风顺的。

Java 并发教程:线程创建

线程可以通过三种方式创建。这里,我们将使用不同的方法来创建同一个线程。

通过继承 Thread 类

创建线程的一种方法是从线程类继承它。然后,您所要做的就是覆盖线程对象的 run() 方法。启动线程时将调用 run() 方法。

public class ExampleThread extends Thread {
    @Override
    public void run() {
        // contains all the code you want to execute
        // when the thread starts

        // prints out the name of the thread
        // which is running the process
        System.out.println(Thread.currentThread().getName());
    }
}

要启动新线程,我们创建上述类的一个实例并在其上调用 start() 方法。

public class ThreadExamples {
    public static void main(String[] args) {
        ExampleThread thread = new ExampleThread();
        thread.start();
    }
}

一个常见的错误是调用 run() 方法来启动线程。这似乎是正确的,因为一切都运行正常,但调用 run() 方法不会启动新线程。相反,它会在父线程内执行线程的代码。我们使用 start() 方法来执行新线程。

您可以通过在上面的代码中调用“thread.run()”而不是“thread.start()”来测试这一点。您会看到控制台中打印了“main”,这意味着我们没有创建任何线程。相反,任务在主线程中执行。有关线程类的更多信息,请务必查看文档

通过实现 Runnable 接口

创建线程的另一种方法是实现 Runnable 接口。与上一种方法类似,您需要重写 run() 方法,该方法将包含您希望可运行线程执行的所有任务。

public class ExampleRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
    }
}

public class ThreadExamples {
    public static void main(String[] args) {
        ExampleRunnable runnable = new ExampleRunnable();
        Thread thread = new Thread(runnable);
        thread.start();
    }
}

这两种方法的工作原理完全相同,性能上没有差异。但是,Runnable 接口提供了使用其他类扩展该类的选项,因为在 Java 中您只能继承一个类。使用 Runnable 创建线程池也更容易。

通过使用匿名声明

该方法与上面的方法非常相似。但是,您不需要创建一个实现 runnable 方法的新类,而是创建一个包含要执行的任务的匿名函数。

public class Main { 
    public static void main(String[] args) { 
        Thread thread = new Thread(() -> { 
            // 要执行的任务
            System.out.println(Thread.currentThread().getName()); 
        }); 
        thread.start(); 
    } 
}

线程方法

如果我们在threadTwo内部调用threadOne.join()方法,它会让threadTwo进入等待状态,直到threadOne执行完毕。

调用Thread.sleep(long timeInMilliSeconds)静态方法将使当前线程进入定时等待状态。

线程生命周期

线程可以处于以下状态之一。使用 Thread.getState() 获取线程的当前状态。

  1. NEW:已创建但还未开始执行
  2. RUNNABLE:已开始执行
  3. BLOCKED:正在等待获取锁
  4. 等待:等待其他线程执行任务
  5. TIMED_WAITING:等待指定的时间段
  6. TERMINATED:执行完成或中止

执行器和线程池

线程需要一些资源才能启动,任务完成后线程就会停止。对于包含许多任务的应用程序,您可能希望将任务排队,而不是创建更多线程。如果我们能够以某种方式重用现有线程,同时限制您可以创建的线程数量,那不是很好吗?

ExecutorService 类允许我们创建一定数量的线程并在线程之间分配任务。由于您创建的是固定数量的线程,因此您可以很好地控制应用程序的性能。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        for (int i = 0; i < 20; i++) {
            int finalI = i;
            executor.submit(() -> System.out.println(Thread.currentThread().getName() + " is executing task " + finalI));
        }
        executor.shutdown();
    }
}

竞争条件

竞争条件是指程序的行为取决于多个线程或进程的相对时间或交错。为了更好地理解这一点,让我们看下面的例子。

public class Increment {
    private int count = 0;

    public void increment() {
        count += 1;
    }

    public int getCount() {
        return this.count;
    }
}

public class RaceConditionsExample {
    public static void main(String[] args) {
        Increment eg = new Increment();
        for (int i = 0; i < 1000; i++) {
            Thread thread = new Thread(eg::increment);
            thread.start();
        }
        System.out.println(eg.getCount());
    }
}

这里,我们有一个 Increment 类,它存储一个变量计数和一个增加计数的函数。在 RaceConditionsExample 中,我们启动了一千个线程,每个线程都会调用increment()方法。最后,我们等待所有线程完成执行,然后打印出计数变量的值。

如果你多次运行该代码,你会注意到,有时 count 的最终值小于 1,000。为了理解为什么会出现这种情况,我们以两个线程 Thread-x 和 Thread-y 为例。线程可以按任何顺序执行读写操作。因此,会出现执行顺序如下的情况。

Thread-x: Reads this.count (which is 0)
Thread-y: Reads this.count (which is 0)
Thread-x: Increments this.count by 1
Thread-y: Increments this.count by 1
Thread-x: Updates this.count (which becomes 1)
Thread-y: Updates this.count (which becomes 1)

在这种情况下,计数变量的最终值为 1,而不是 2。这是因为两个线程都在读取计数变量,然后它们中的任何一个才能更新该值。这被称为竞争条件。更具体地说,是“读取-修改-写入”竞争条件。

同步策略

在上一节中,我们研究了什么是竞争条件。为了避免竞争条件,我们需要同步任务。在本节中,我们将介绍在多个线程中同步不同进程的不同方法。

有时候你会希望某项任务每次只由一个线程执行。但是你如何确保某项任务只由一个线程执行呢?

一种方法是使用锁。这个想法是创建一个锁对象,每次只能由一个线程“获取”。在执行任务之前,线程会尝试获取锁。如果成功,它会继续执行任务。一旦完成任务,它就会释放锁。如果线程无法获取锁,则意味着该任务正在由另一个线程执行。

下面是一个使用 ReentrantLock 类的示例,该类是锁接口的一个实现。

import java.util.concurrent.locks.ReentrantLock;


public class LockExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public int increment() {
        lock.lock();
        try {
            return this.count++;
        } finally {
            lock.unlock();
        }
    }
}

当我们在线程中调用 lock() 方法时,它会尝试获取锁。如果成功,它会执行任务。但是,如果不成功,线程将被阻塞,直到锁被释放。

isLocked() 返回一个布尔值,取决于是否可以获取锁。

tryLock() 方法尝试以非阻塞方式获取锁。如果成功则返回 true,否则返回 false。

unlock() 方法释放锁。

读写锁

当使用共享数据和资源时,通常您需要两件事:

  1. 如果资源没有被写入,则多个线程应该能够同时读取资源。
  2. 如果没有其他线程正在读取或写入共享资源,则一次只有一个线程可以写入该资源。

ReadWriteLock 接口通过使用两个锁而不是一个锁来实现这一点。如果没有线程获取写锁,则读锁可以一次由多个线程获取。只有当读写锁都未获取时,才能获取写锁。

这里举个例子来说明。假设我们有一个 SharedCache 类,它只是存储键值对,如下所示。

public class SharedCache {
    private Map<String, String> cache = new HashMap<>();

    public String readData(String key) {
        return cache.get(key);
    }

    public void writeData(String key, String value) {
        cache.put(key, value);
    }
}

我们希望多个线程同时读取缓存(当缓存未被写入时)。但一次只能有一个线程写入缓存。为了实现这一点,我们将使用 ReentrantReadWriteLock,它是 ReadWriteLock 接口的一个实现。

public class SharedCache {
    private Map<String, String> cache = new HashMap<>();
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    
    public String readData(String key) {
        lock.readLock().lock();
        try {
            return cache.get(key);
        } finally {
            lock.readLock().unlock();
        }
    }
    

    public void writeData(String key, String value) {
        lock.writeLock().lock();
        try {
            cache.put(key, value);
        } finally {
            lock.writeLock().unlock();
        }
    }
}

同步块和方法

同步块是一段每次只能由一个线程执行的 Java 代码。它们是实现跨线程同步的简单方法。

synchronized (Object reference_object) {
 // code you want to be synchronized
}

创建同步块时,需要传递一个引用对象。上例中“this”或当前对象是引用对象,这意味着如果创建了多个实例,它们将不会被同步。

您还可以使用synchronized关键字来同步方法。

public synchronized int increment();

死锁

当两个或多个线程因各自等待对方释放资源或执行特定操作而无法继续执行时,就会发生死锁。结果,它们无限期地陷入停滞状态,无法取得进展。

考虑一下,有两个线程和两个锁(我们称它们为线程 A、线程 B、锁 A 和锁 B)。线程 A 将首先尝试获取锁 A,如果成功,它将尝试获取锁 B。另一方面,线程 B 则首先尝试获取锁 B,然后再获取锁 A。

import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static void main(String[] args) {
        ReentrantLock lockA = new ReentrantLock();
        ReentrantLock lockB = new ReentrantLock();

        Thread threadA = new Thread(() -> {
            lockA.lock();
            try {
                System.out.println("Thread-A has acquired Lock-A");
                lockB.lock();
                try {
                    System.out.println("Thread-A has acquired Lock-B");
                } finally {
                    lockB.unlock();
                }
            } finally {
                lockA.unlock();
            }
        });

        Thread threadB = new Thread(() -> {
            lockB.lock();
            try {
                System.out.println("Thread-B has acquired Lock-B");
                lockA.lock();
                try {
                    System.out.println("Thread-B has acquired Lock-A");
                } finally {
                    lockA.unlock();
                }
            } finally {
                lockB.unlock();
            }
        });
        
        threadA.start();
        threadB.start();
    }
}

这里,ThreadA 获取了锁 A,正在等待获取锁 B。ThreadB 已获取锁 B,正在等待获取锁 A。这里,线程 A 永远不会获取锁 B,因为锁 B 由线程 B 持有。同样,线程 B 也永远不会获取锁 A,因为锁 A 由线程 A 持有。这种情况称为死锁。

以下是避免死锁需要记住的一些要点。

  1. 定义获取资源的严格顺序。所有线程在请求​​资源时都必须遵循相同的顺序。
  2. 避免嵌套锁或同步块。上例中死锁的原因是线程无法在未获取另一个锁的情况下释放一个锁。
  3. 确保线程不会同时获取多个资源。如果一个线程拥有一个资源并需要另一个资源,它应该在尝试获取第二个资源之前释放第一个资源。这可以防止循环依赖并降低死锁的可能性。
  4. 设置获取锁或资源时的超时时间。如果线程在指定时间内未能获取锁,则会释放所有已获取的锁,稍后再试。这可防止线程无限期持有锁,从而可能导致死锁的情况。

Java 并发集合

Java 平台在“java.util.concurrent”包中提供了几个并发集合,它们被设计为线程安全的并支持并发访问。

并发哈希映射

ConcurrentHashMap 是 HashMap 的线程安全替代方案。它提供针对原子更新和检索操作优化的方法。例如,putIfAbsent()、remove() 和 replace() 方法以原子方式执行操作并避免竞争条件。

写时复制数组列表

设想这样一个场景:一个线程试图读取或迭代一个数组列表,而另一个线程试图修改它。这可能会导致读取操作不一致,甚至会抛出 ConcurrentModificationException。

CopyOnWriteArrayList 通过在每次修改时复制整个数组的内容来解决此问题。这样,我们可以在修改新副本时迭代上一个副本。

CopyOnWriteArrayList 的线程安全机制是有代价的。修改操作(例如添加或删除元素)的代价很高,因为它们需要创建底层数组的新副本。这使得 CopyOnWriteArrayList 适用于读取频率高于写入频率的场景。

Java 中并发的替代方案

如果您希望构建独立于操作系统的高性能应用程序,那么选择 Java 并发性是一个不错的选择。但这并不是唯一的选择。以下是您可以考虑的一些替代方案。

Go 编程语言,通常称为 Golang,是 Google 开发的一种静态类型编程语言,广泛应用于 Go 开发服务。该软件解决方案因其高效和精简的性能而备受赞誉,尤其是在处理并发操作方面。Go 在并发性方面的优势在于它使用了 goroutines,即由 Go 运行时管理的轻量级线程,这使得并发编程既简单又高效。

Scala 是一种兼容 JVM 的语言,完美地融合了函数式和面向对象范式,是许多Scala 开发公司的首选。其最强大的功能之一是名为Akka 的强大库。Akka 专为处理并发操作而设计,它采用 Actor 模型进行并发,为传统的基于线程的并发提供了一种直观且不易出错的替代方案。

Python 是Python 开发服务中广泛使用的语言,它配备了并发编程库,例如 asyncio、multiprocessing 和 threading。这些库使开发人员能够有效地管理并行和并发执行。但是,重要的是要注意 Python 的全局解释器锁 (GIL),它会限制线程的效率,尤其是对于 CPU 密集型任务。

Erlang /OTP 是一种函数式编程语言,专为构建高并发系统而设计。OTP 是一种中间件,它提供了用于开发此类系统的设计原则和库的集合。

结论

Java 并发性通过并行计算和多线程为提高应用程序性能打开了大门,这对多核处理器时代的开发人员来说是一笔宝贵的财富。它利用强大的 API,使并发编程高效可靠。然而,它确实带来了一系列挑战。Java开发人员必须小心管理共享资源,以避免死锁、竞争条件或线程干扰等问题。Java 中并发编程的复杂性可能需要更多时间来掌握,但应用程序性能的潜在好处使其成为一项值得的努力。


网站公告

今日签到

点亮在社区的每一天
去签到