MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java并发编程最佳实践

2024-07-307.7k 阅读

线程安全基础

在Java并发编程中,线程安全是一个核心概念。当多个线程访问一个对象时,如果不考虑这些线程的调度和交替运行,并且不需要额外的同步操作,这个对象的行为仍然是正确的,那么这个对象就是线程安全的。

共享资源与竞争条件

当多个线程同时访问和修改共享资源时,就可能出现竞争条件。例如,假设有一个简单的计数器类:

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

如果有多个线程同时调用increment方法,就可能出现问题。因为count++操作实际上包含了读取、增加和写入三个步骤,这不是一个原子操作。当多个线程并发执行时,可能会出现数据不一致的情况。

原子性操作

为了解决上述问题,可以使用java.util.concurrent.atomic包中的原子类。例如,AtomicInteger

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

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

AtomicInteger中的incrementAndGet方法是原子操作,保证了在多线程环境下的正确性。

同步机制

虽然原子类可以解决一部分线程安全问题,但对于更复杂的操作,我们需要使用同步机制。

synchronized关键字

synchronized关键字可以用来修饰方法或代码块。当一个线程进入同步方法或同步代码块时,它会获取对象的锁。其他线程如果想要进入相同对象的同步方法或同步代码块,就必须等待锁的释放。

同步方法

public class SynchronizedCounter {
    private int count = 0;

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

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

在这个例子中,incrementgetCount方法都被synchronized修饰,这意味着在任何时刻,只有一个线程可以执行这些方法。

同步代码块

public class SynchronizedBlockCounter {
    private int count = 0;
    private final Object lock = new Object();

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

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

这里使用synchronized块,并指定了一个锁对象lock。同步块的粒度可以更细,相比于同步方法,在某些情况下可以提高性能。

重入锁(ReentrantLock)

ReentrantLock是Java 5.0引入的一种更灵活的锁机制。它与synchronized关键字类似,但提供了更多的功能,如可中断的锁获取、公平锁等。

import java.util.concurrent.locks.ReentrantLock;

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

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

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

使用ReentrantLock时,需要手动调用lockunlock方法,并且要确保在finally块中释放锁,以避免死锁。

线程间通信

在多线程编程中,线程之间常常需要进行通信和协作。

wait() 和 notify()/notifyAll()

这三个方法是定义在Object类中的,用于线程间的通信。wait方法会使当前线程等待,直到其他线程调用notifynotifyAll方法。

public class ProducerConsumer {
    private final Object lock = new Object();
    private int value;
    private boolean available = false;

    public void produce(int v) {
        synchronized (lock) {
            while (available) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
            value = v;
            available = true;
            lock.notify();
        }
    }

    public int consume() {
        synchronized (lock) {
            while (!available) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
            available = false;
            lock.notify();
            return value;
        }
    }
}

在这个生产者 - 消费者的例子中,produce方法在数据已经可用时等待,然后生产数据并通知消费者;consume方法在数据不可用时等待,然后消费数据并通知生产者。

Condition接口

Condition接口是在java.util.concurrent.locks包中,它提供了比waitnotify更灵活的线程间通信方式。

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

public class ConditionProducerConsumer {
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    private int value;
    private boolean available = false;

    public void produce(int v) {
        lock.lock();
        try {
            while (available) {
                notFull.await();
            }
            value = v;
            available = true;
            notEmpty.signal();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
    }

    public int consume() {
        lock.lock();
        try {
            while (!available) {
                notEmpty.await();
            }
            available = false;
            notFull.signal();
            return value;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
        return -1;
    }
}

Condition通过Lock创建,每个Condition可以有自己的等待队列,相比于waitnotify更加灵活。

线程池与并发工具

为了更好地管理和复用线程,Java提供了线程池和一系列并发工具。

线程池

线程池可以避免频繁创建和销毁线程带来的开销。java.util.concurrent.Executors类提供了一些创建线程池的静态方法。

FixedThreadPool:创建一个固定大小的线程池。

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

public class FixedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 5; i++) {
            executorService.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " is running");
            });
        }
        executorService.shutdown();
    }
}

在这个例子中,线程池大小为3,意味着最多同时有3个线程在执行任务,其余任务会在队列中等待。

CachedThreadPool:创建一个可缓存的线程池,如果线程池中的线程在60秒内未被使用,就会被回收。

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

public class CachedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 5; i++) {
            executorService.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " is running");
            });
        }
        executorService.shutdown();
    }
}

ScheduledThreadPool:创建一个可以执行定时任务的线程池。

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2);
        executorService.scheduleAtFixedRate(() -> {
            System.out.println(Thread.currentThread().getName() + " is running on schedule");
        }, 0, 1, TimeUnit.SECONDS);
    }
}

这里,任务会每隔1秒执行一次。

并发集合

Java提供了一些线程安全的集合类,如ConcurrentHashMapCopyOnWriteArrayList等。

ConcurrentHashMap

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        map.put("key1", 1);
        int value = map.get("key1");
        System.out.println(value);
    }
}

ConcurrentHashMap提供了高效的线程安全的哈希表实现,允许多个线程同时进行读操作,并且在写操作时也能保证一定的并发性能。

CopyOnWriteArrayList

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListExample {
    public static void main(String[] args) {
        List<String> list = new CopyOnWriteArrayList<>();
        list.add("element1");
        for (String element : list) {
            System.out.println(element);
        }
    }
}

CopyOnWriteArrayList在进行写操作时,会复制一份原数组,在新数组上进行修改,然后将原数组引用指向新数组。读操作则直接读取原数组,这样保证了读操作的线程安全,并且读操作不会被写操作阻塞。

并发编程中的常见问题与解决方案

在并发编程过程中,会遇到一些常见的问题,需要我们采取相应的解决方案。

死锁

死锁是指两个或多个线程互相持有对方需要的锁,而互相等待对方释放锁的情况。例如:

public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1 holds lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1 holds lock2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2 holds lock2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (lock1) {
                    System.out.println("Thread 2 holds lock1");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个例子中,thread1获取了lock1,然后尝试获取lock2thread2获取了lock2,然后尝试获取lock1,从而导致死锁。

解决方案

  • 破坏死锁的四个必要条件(互斥、占有并等待、不可剥夺、循环等待)。例如,通过按顺序获取锁来避免循环等待。
  • 使用ReentrantLock的可中断锁获取,如:
import java.util.concurrent.locks.ReentrantLock;

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

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            boolean gotLock1 = false;
            boolean gotLock2 = false;
            try {
                if (lock1.tryLock()) {
                    gotLock1 = true;
                    System.out.println("Thread 1 holds lock1");
                    if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
                        gotLock2 = true;
                        System.out.println("Thread 1 holds lock2");
                    }
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                if (gotLock2) {
                    lock2.unlock();
                }
                if (gotLock1) {
                    lock1.unlock();
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            boolean gotLock1 = false;
            boolean gotLock2 = false;
            try {
                if (lock2.tryLock()) {
                    gotLock2 = true;
                    System.out.println("Thread 2 holds lock2");
                    if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
                        gotLock1 = true;
                        System.out.println("Thread 2 holds lock1");
                    }
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                if (gotLock1) {
                    lock1.unlock();
                }
                if (gotLock2) {
                    lock2.unlock();
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

这里使用tryLock方法尝试获取锁,并设置了超时时间,避免了死锁。

活锁

活锁是指线程虽然没有发生死锁,但由于相互之间不断地改变状态而无法继续执行。例如,两个线程都尝试避让对方,结果不断地重复避让动作。

解决方案

  • 引入随机性。例如,在重试操作前引入随机的等待时间,使线程不会同时重试,从而避免活锁。

饥饿

当某些线程由于优先级低等原因,长时间得不到执行,就会出现饥饿现象。

解决方案

  • 合理设置线程优先级,但要注意在不同操作系统上线程优先级的表现可能不同。
  • 使用公平锁,如ReentrantLock可以通过构造函数设置为公平锁,使得等待时间最长的线程优先获取锁。

性能优化

在并发编程中,性能优化是一个重要的方面。

减少锁的粒度

通过缩小同步块的范围,可以减少线程等待锁的时间,提高并发性能。例如,在之前的SynchronizedBlockCounter类中,只对关键的count操作进行同步,而不是对整个方法进行同步。

读写锁

当读操作远远多于写操作时,可以使用读写锁。ReentrantReadWriteLock类提供了读写锁的实现。读锁可以被多个线程同时持有,而写锁则是独占的。

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private int value;
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public int read() {
        lock.readLock().lock();
        try {
            return value;
        } finally {
            lock.readLock().unlock();
        }
    }

    public void write(int v) {
        lock.writeLock().lock();
        try {
            value = v;
        } finally {
            lock.writeLock().unlock();
        }
    }
}

这样,多个线程可以同时进行读操作,只有在写操作时才需要独占锁,提高了并发性能。

无锁数据结构

使用无锁数据结构可以避免锁带来的开销。例如,ConcurrentLinkedQueue是一个基于链表的无锁队列,适合在高并发环境下使用。

import java.util.concurrent.ConcurrentLinkedQueue;

public class ConcurrentLinkedQueueExample {
    public static void main(String[] args) {
        ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();
        queue.add(1);
        Integer value = queue.poll();
        System.out.println(value);
    }
}

无锁数据结构通常使用原子操作和乐观锁机制来保证数据的一致性和线程安全。

并发编程的测试与调试

在开发并发程序时,测试和调试是确保程序正确性的重要环节。

单元测试

使用单元测试框架(如JUnit)对并发代码进行测试。例如,对AtomicCounter类进行测试:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class AtomicCounterTest {
    @Test
    public void testIncrement() {
        AtomicCounter counter = new AtomicCounter();
        counter.increment();
        assertEquals(1, counter.getCount());
    }
}

对于多线程相关的测试,可以使用CountDownLatch等工具来同步线程。例如,测试多个线程同时对AtomicCounter进行操作:

import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class AtomicCounterMultiThreadTest {
    @Test
    public void testMultiThreadIncrement() throws InterruptedException {
        AtomicCounter counter = new AtomicCounter();
        int threadCount = 10;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                counter.increment();
                latch.countDown();
            }).start();
        }

        latch.await();
        assertEquals(threadCount, counter.getCount());
    }
}

调试工具

  • jstack:可以用于生成Java虚拟机当前时刻的线程快照,通过分析线程快照,可以找出死锁、线程长时间等待等问题。例如,在命令行中执行jstack <pid>,其中<pid>是Java进程的ID。
  • VisualVM:是一个功能强大的Java性能分析工具,可以直观地查看线程状态、CPU和内存使用情况等,方便调试并发程序。

总结并发编程最佳实践要点

  1. 使用线程安全的数据结构:尽量使用Java提供的线程安全集合类,如ConcurrentHashMapCopyOnWriteArrayList等,避免自己实现复杂的线程安全数据结构,减少出错风险。
  2. 控制锁的粒度:减小同步块的范围,只对关键的共享资源操作进行同步,以提高并发性能。同时,合理选择锁机制,如synchronized关键字和ReentrantLock,根据具体需求选择公平锁或非公平锁。
  3. 避免死锁:按照一定的顺序获取锁,避免循环等待。可以使用tryLock方法并设置超时时间来预防死锁的发生。
  4. 线程间通信:使用waitnotify/notifyAllCondition接口来实现线程间的协作和通信,确保线程之间能够正确地交互数据。
  5. 合理使用线程池:根据任务的类型和数量选择合适的线程池,如固定大小线程池、可缓存线程池或定时任务线程池,避免频繁创建和销毁线程带来的开销。
  6. 性能优化:采用读写锁、无锁数据结构等技术来提高并发性能,减少锁争用。同时,注意在高并发环境下对性能进行监控和调优。
  7. 充分测试与调试:使用单元测试框架对并发代码进行全面的测试,包括单线程和多线程场景。利用调试工具,如jstackVisualVM等,及时发现和解决死锁、线程饥饿等问题。

通过遵循这些最佳实践,可以编写出更加健壮、高效的Java并发程序。在实际开发中,还需要根据具体的业务需求和场景,灵活运用各种并发编程技术,以达到最优的性能和稳定性。