引言

并发编程在Java开发中变得越来越重要,尤其是在需要处理大量请求、数据或任务的后台系统中。并发的正确实现能够显著提升系统的吞吐量与性能,但如果设计不当,则可能带来数据不一致、死锁、线程饥饿等问题。

本文将深入剖析Java并发编程中的关键问题,包括线程同步、数据一致性、常见并发工具的使用等。我们将从实际应用出发,介绍如何用Java的并发工具正确地处理并发任务,并分享性能优化的技巧。

1. Java并发问题的根源

并发问题的核心在于多线程操作共享资源的过程中产生的数据竞争。线程之间通常会共享内存资源,比如变量、集合等,造成数据在被多个线程访问时容易产生不一致的情况。

1.1 数据竞争

当多个线程并发访问同一变量或数据结构且至少有一个线程对其进行了写操作时,就可能产生数据竞争。例如,多个线程同时读取和修改一个共享计数器会导致最终结果不一致。

1
2
3
4
5
6
7
8
9
10
11
12
public class Counter {
private int count = 0;

public void increment() {
count++; // 数据竞争,多个线程并发调用时,结果会出现错误
}

public int getCount() {
return count;
}
}

1.2 可见性问题

Java中线程之间的变量是不可见的,除非使用特定的机制(如synchronized或volatile关键字)。这会导致一个线程修改了变量的值,但其他线程无法感知到该变化,仍然会读取旧值。

2. 并发工具的选择

Java提供了许多并发工具来解决这些问题,最常用的有volatile、synchronized、Lock、Atomic类和并发集合等。

2.1 volatile

volatile关键字可确保变量的可见性,即当一个线程修改volatile变量的值时,其他线程会立即读取到最新的值。但是,volatile无法保证操作的原子性,因此适用于简单的状态标识等场景。

1
2
3
4
5
6
7
8
public class VolatileExample {
private volatile boolean running = true;

public void stop() {
running = false; // 更新volatile变量,确保其他线程可见
}
}

2.2 synchronized

synchronized关键字可以确保线程以排他的方式访问共享资源。虽然可以解决数据竞争,但由于使用锁会导致线程阻塞,因此在高并发场景中可能影响性能。

1
2
3
4
5
6
7
8
9
10
11
12
public class SynchronizedCounter {
private int count = 0;

public synchronized void increment() {
count++;
}

public synchronized int getCount() {
return count;
}
}

2.3 Lock接口

Java的Lock接口提供了比synchronized更灵活的锁控制机制,例如超时获取锁、可中断锁等。ReentrantLock是最常用的Lock实现,可以显式加锁和解锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

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

public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}

3. 并发场景实战:Java中的高并发任务执行

假设我们有一个任务队列,需要多线程处理。如何用Java的并发工具高效、可靠地处理这些任务?

3.1 使用线程池

线程池可以有效地管理并复用线程,减少频繁创建和销毁线程带来的开销。Java提供了Executors工具类来创建线程池,例如FixedThreadPool、CachedThreadPool、ScheduledThreadPool等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TaskProcessor {
private final ExecutorService executor = Executors.newFixedThreadPool(10);

public void processTask(Runnable task) {
executor.submit(task);
}

public void shutdown() {
executor.shutdown();
}
}

3.2 使用阻塞队列进行任务调度

在生产者-消费者模型中,可以使用阻塞队列(如ArrayBlockingQueue或LinkedBlockingQueue)来协调任务。任务生产者向队列添加任务,消费者从队列中取出任务并处理,避免线程争用资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class TaskScheduler {
private final BlockingQueue<Runnable> taskQueue = new LinkedBlockingQueue<>();

public void produceTask(Runnable task) {
taskQueue.offer(task);
}

public void consumeTasks() {
while (true) {
try {
Runnable task = taskQueue.take(); // 取出并执行任务
new Thread(task).start();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}

4. 并发性能优化技巧

在实际应用中,仅仅解决并发问题是不够的,还需要优化性能,确保程序能够高效处理任务。

4.1 避免锁的过度使用

在需要高并发的情况下,尽量减少锁的使用。可以考虑使用Atomic类或锁分段的方式减小锁的粒度,从而提升吞吐量。

4.2 使用无锁数据结构

Java的并发包提供了无锁数据结构(如ConcurrentHashMap、ConcurrentLinkedQueue等),这些数据结构可以在高并发场景中提供更好的性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentMapExample {
private ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

public void addData(String key, Integer value) {
map.putIfAbsent(key, value);
}

public Integer getData(String key) {
return map.get(key);
}
}

4.3 使用线程安全的懒加载单例模式

在某些场景中,可以使用懒加载的单例模式来确保资源的唯一性,并延迟创建资源,避免不必要的资源开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

5. 常见并发问题及解决方案

5.1 死锁

死锁是指多个线程互相等待对方释放锁,导致程序永久阻塞。为避免死锁,尽量减少嵌套锁,或使用tryLock设置超时时间以避免长时间等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.concurrent.locks.ReentrantLock;

public class DeadlockAvoidance {
private final ReentrantLock lock1 = new ReentrantLock();
private final ReentrantLock lock2 = new ReentrantLock();

public void executeTask() {
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try {
// 执行任务
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
}
}

5.2 线程饥饿的成因与解决方案

线程饥饿是指某些线程在程序执行过程中长期无法获取所需的资源,从而无法执行其任务。这种现象通常发生在多线程环境中,尤其是高并发场景下。当资源被其他线程占用过多或某些线程的优先级过高时,低优先级线程往往会面临长时间等待,甚至得不到执行机会。

线程饥饿的原因
线程饥饿通常由以下几个原因导致:

不公平锁的使用:当一个锁不是公平锁时,资源的分配可能会偏向于某些线程,导致部分线程始终无法获得锁。例如,某些线程频繁地获取锁并释放,而其他线程却被长时间阻塞。

任务优先级差异:在一些线程池中,如果高优先级任务始终占用资源,那么低优先级任务就可能一直得不到执行机会。

资源竞争:当有限的资源(如I/O设备、数据库连接等)被频繁使用时,某些线程可能始终无法获得资源,从而造成饥饿。

大量长时间任务:如果系统中存在大量需要长时间执行的任务,其他需要短暂执行的任务也可能因为缺乏资源被延迟执行,导致饥饿。

线程饥饿的解决方案
在实际开发中,避免线程饥饿的方式主要包括使用公平锁、限制任务队列长度、调整线程优先级等。

1. 使用公平锁(Fair Lock)

公平锁是一种确保先请求资源的线程优先获得资源的锁机制。Java中的ReentrantLock提供了公平锁的支持。当我们创建一个ReentrantLock实例时,可以通过设置构造函数的参数为true来启用公平锁。这样可以保证线程按照请求锁的先后顺序获取锁,从而有效避免某些线程始终无法获取锁的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.concurrent.locks.ReentrantLock;

public class FairLockExample {
private final ReentrantLock lock = new ReentrantLock(true); // 启用公平锁

public void accessResource() {
lock.lock();
try {
// 执行临界区代码
System.out.println(Thread.currentThread().getName() + " 获得锁并正在执行任务");
} finally {
lock.unlock();
}
}

public static void main(String[] args) {
FairLockExample example = new FairLockExample();

// 创建多个线程模拟高并发
for (int i = 0; i < 10; i++) {
new Thread(example::accessResource).start();
}
}
}

在以上示例中,启用了公平锁后,系统会确保线程按照先请求先获取的原则来分配锁。这有效避免了资源“偏向”某些线程的情况。

2. 限制任务队列长度

在线程池中,我们可以限制任务队列的长度以防止饥饿。当任务队列过长时,低优先级任务可能长期得不到执行机会。通过设置一个合理的任务队列长度,可以避免某些线程因资源长期被占用而出现饥饿现象。

在Java的线程池ThreadPoolExecutor中,我们可以通过构造函数设置任务队列的大小。例如,使用ArrayBlockingQueue设置固定长度的队列,一旦队列达到最大容量,新任务将被拒绝或执行自定义处理逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.concurrent.*;

public class LimitedQueueExample {

public static void main(String[] args) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 10L;

BlockingQueue<Runnable> taskQueue = new ArrayBlockingQueue<>(2); // 设置队列大小为2

ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
taskQueue,
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);

// 模拟多个任务提交
for (int i = 0; i < 10; i++) {
final int taskNumber = i;
executor.execute(() -> {
System.out.println("执行任务:" + taskNumber + " - " + Thread.currentThread().getName());
try {
Thread.sleep(1000); // 模拟任务执行
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}

executor.shutdown();
}
}

在这个示例中,线程池的核心池大小为2,最大池大小为4,且任务队列的大小为2。当任务数超过队列和线程池容量时,会执行拒绝策略,此处采用CallerRunsPolicy,即新任务将由调用者线程(主线程)执行,从而减轻线程池负载并避免低优先级任务长期等待的情况。

3. 调整线程优先级

在某些情况下,通过调整线程的优先级可以部分缓解饥饿问题。Java中每个线程都有优先级,通常范围从MIN_PRIORITY(1)到MAX_PRIORITY(10)。默认优先级为NORM_PRIORITY(5)。较高优先级的线程会比低优先级的线程获得更多的CPU执行时间。

不过,线程优先级的调整效果取决于操作系统的调度算法。在Java应用中使用优先级调整时,应谨慎使用并合理分配,以免造成意外的性能问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class PriorityExample {

public static void main(String[] args) {
Thread highPriorityThread = new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.println("高优先级线程执行:" + i);
}
});
Thread lowPriorityThread = new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.println("低优先级线程执行:" + i);
}
});

highPriorityThread.setPriority(Thread.MAX_PRIORITY); // 设置高优先级
lowPriorityThread.setPriority(Thread.MIN_PRIORITY); // 设置低优先级

highPriorityThread.start();
lowPriorityThread.start();
}
}

在这个例子中,highPriorityThread线程被设置为最高优先级,lowPriorityThread线程被设置为最低优先级。在大多数情况下,高优先级线程将比低优先级线程获得更多的执行机会,但在某些操作系统上,优先级差异可能不明显。

总结

Java并发编程是一项需要高度理解和实践的技能。通过合理选择并发工具(如synchronized、Lock、Atomic类等)并结合优化技巧,我们可以有效地解决并发问题,提升程序性能。

推荐正在找工作的朋友们:

就业指导面试指导 (不是机构)

个人公众号:Java直达Offer

qrcode for gh 17fde7ca11d7 258