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

Java Web应用中的并发控制

2021-10-032.9k 阅读

并发控制的概念与背景

在Java Web应用开发中,随着应用规模的扩大和用户量的增加,并发访问成为了一个必须面对的重要问题。当多个用户同时访问Web应用的资源,如数据库、文件系统或共享内存区域时,如果没有合适的并发控制机制,就可能会导致数据不一致、脏读、丢失更新等一系列问题。

想象一个在线银行转账的场景,用户A向用户B转账100元。这个操作通常涉及从A的账户减去100元,然后在B的账户加上100元。如果两个转账操作同时进行,在没有并发控制的情况下,就可能出现数据错误。例如,第一个操作读取A的账户余额为1000元,正要减去100元时,第二个操作也读取了A的账户余额1000元,然后两个操作都执行减法,最终A的账户余额变成了800元,而不是预期的900元。

Java Web应用中的并发场景

  1. 多用户请求同一资源:在一个电商应用中,多个用户可能同时尝试购买同一款限量商品。每个用户的购买请求都会尝试减少商品库存。如果没有适当的并发控制,库存数量可能会被错误地修改,导致超卖现象。
  2. 分布式系统中的并发:在微服务架构的Java Web应用中,不同的微服务可能会同时访问共享的数据库或缓存。例如,用户服务和订单服务可能同时更新用户的积分信息,这就需要协调它们之间的并发操作。

并发控制的目标

  1. 数据一致性:确保在并发访问下,数据的状态始终保持一致。无论有多少并发操作,数据都应该反映正确的业务逻辑。
  2. 系统性能:并发控制机制不应过度影响系统的性能。合理的并发控制应该在保证数据一致性的前提下,尽量提高系统的并发处理能力。

基于锁机制的并发控制

内置锁(Synchronized关键字)

  1. 原理:Java中的synchronized关键字提供了一种内置的锁机制。当一个线程进入一个被synchronized修饰的方法或代码块时,它会自动获取对象的锁。其他线程如果想要进入相同对象的synchronized方法或代码块,就必须等待锁的释放。
  2. 示例代码
public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    // 使用synchronized修饰方法
    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }

    public double getBalance() {
        return balance;
    }
}

在上述代码中,depositwithdraw方法被synchronized修饰,这意味着当一个线程执行其中一个方法时,其他线程无法同时执行这两个方法中的任何一个。

  1. 优缺点
    • 优点:使用简单,Java语言内置支持,无需额外引入复杂的库。
    • 缺点:粒度较粗,如果一个方法中包含大量的代码,即使只有一小部分需要同步,整个方法都会被锁住,可能导致性能瓶颈。

显式锁(Lock接口及其实现类)

  1. 原理:Java 5.0引入了java.util.concurrent.locks.Lock接口,它提供了比synchronized更灵活的锁控制。Lock接口的实现类,如ReentrantLock,允许更细粒度的锁控制,例如可以尝试获取锁而不阻塞,还可以设置锁的公平性。
  2. 示例代码
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

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

    public SafeCounter() {
        count = 0;
    }

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

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

在这段代码中,ReentrantLock被用于保护count变量的修改和读取。通过lock()方法获取锁,unlock()方法释放锁,并且在try - finally块中释放锁以确保锁一定会被释放。

  1. 优缺点
    • 优点:提供了更灵活的锁操作,如可中断的锁获取、公平锁机制等。可以实现更细粒度的锁控制,提高并发性能。
    • 缺点:使用相对复杂,需要手动获取和释放锁,如果忘记释放锁可能导致死锁。

基于原子类的并发控制

原子类概述

Java的java.util.concurrent.atomic包提供了一系列原子类,如AtomicIntegerAtomicLong等。这些类通过硬件级别的原子操作,保证了在多线程环境下对数据的操作是原子性的,无需额外的锁机制。

示例代码

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();
    }
}

在上述代码中,AtomicIntegerincrementAndGet方法是原子操作,多个线程同时调用该方法不会导致数据竞争。

适用场景

原子类适用于对单个变量的简单操作,如计数、累加等场景。由于它们不需要锁,性能通常比使用锁机制更好。但对于复杂的操作,可能需要结合锁机制或其他并发控制手段。

线程安全的集合类

概述

在Java Web应用中,经常需要使用集合类来存储和管理数据。普通的集合类,如ArrayListHashMap等,不是线程安全的,在多线程环境下使用可能会出现数据不一致的问题。Java提供了一些线程安全的集合类。

常见线程安全集合类

  1. VectorHashtable:这两个类是Java早期提供的线程安全集合类。Vector类似于ArrayListHashtable类似于HashMap。它们通过对方法进行synchronized修饰来实现线程安全。
import java.util.Vector;

public class VectorExample {
    private Vector<String> vector = new Vector<>();

    public void addElement(String element) {
        vector.addElement(element);
    }

    public String getElement(int index) {
        return vector.elementAt(index);
    }
}
  1. ConcurrentHashMapConcurrentHashMap是Java 5.0引入的线程安全的哈希表。它采用了分段锁的机制,允许多个线程同时访问不同的段,提高了并发性能。
import java.util.concurrent.ConcurrentHashMap;

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

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

    public Integer get(String key) {
        return map.get(key);
    }
}
  1. CopyOnWriteArrayListCopyOnWriteArraySet:这两个类在修改操作(如添加、删除元素)时,会创建一个新的数组副本,而读操作则直接读取原数组。这样保证了读操作的高效性和线程安全性,但写操作相对较慢。
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListExample {
    private CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();

    public void addElement(String element) {
        list.add(element);
    }

    public Iterator<String> getIterator() {
        return list.iterator();
    }
}

选择合适的线程安全集合类

  1. 如果读操作远远多于写操作,CopyOnWriteArrayListCopyOnWriteArraySet可能是不错的选择。
  2. 对于哈希表类型的集合,如果需要高并发写入,ConcurrentHashMap是更好的选择,而Hashtable由于锁粒度较大,性能相对较差。
  3. Vector在现代Java开发中使用较少,因为ArrayList结合合适的并发控制机制通常能提供更好的性能。

事务管理与并发控制

数据库事务概念

在Java Web应用中,数据库是最常见的共享资源。数据库事务是一组数据库操作的集合,这些操作要么全部成功,要么全部失败。例如,在银行转账操作中,从一个账户扣款和向另一个账户存款应该在同一个事务中,以保证数据的一致性。

Java中的事务管理

  1. JDBC事务管理:通过Connection对象的commitrollback方法来管理事务。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class JdbcTransactionExample {
    private static final String URL = "jdbc:mysql://localhost:3306/mydb";
    private static final String USER = "root";
    private static final String PASSWORD = "password";

    public static void transfer(double amount, int fromAccountId, int toAccountId) {
        try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD)) {
            connection.setAutoCommit(false);
            String updateFrom = "UPDATE accounts SET balance = balance -? WHERE account_id =?";
            String updateTo = "UPDATE accounts SET balance = balance +? WHERE account_id =?";
            try (PreparedStatement fromStmt = connection.prepareStatement(updateFrom);
                 PreparedStatement toStmt = connection.prepareStatement(updateTo)) {
                fromStmt.setDouble(1, amount);
                fromStmt.setInt(2, fromAccountId);
                fromStmt.executeUpdate();
                toStmt.setDouble(1, amount);
                toStmt.setInt(2, toAccountId);
                toStmt.executeUpdate();
                connection.commit();
            } catch (SQLException e) {
                connection.rollback();
                e.printStackTrace();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}
  1. 基于框架的事务管理:如Spring框架提供了声明式事务管理和编程式事务管理。声明式事务管理通过在配置文件或使用注解来定义事务边界,而编程式事务管理则通过编写代码来控制事务。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class SpringTransactionExample {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Transactional
    public void transfer(double amount, int fromAccountId, int toAccountId) {
        String updateFrom = "UPDATE accounts SET balance = balance -? WHERE account_id =?";
        String updateTo = "UPDATE accounts SET balance = balance +? WHERE account_id =?";
        jdbcTemplate.update(updateFrom, amount, fromAccountId);
        jdbcTemplate.update(updateTo, amount, toAccountId);
    }
}

在上述Spring示例中,@Transactional注解表示该方法在一个事务中执行,如果方法执行过程中出现异常,事务会自动回滚。

事务隔离级别

  1. 读未提交(Read Uncommitted):一个事务可以读取另一个未提交事务的数据。这种隔离级别可能会导致脏读问题。
  2. 读已提交(Read Committed):一个事务只能读取已提交事务的数据。可以避免脏读,但可能会出现不可重复读问题。
  3. 可重复读(Repeatable Read):在一个事务内多次读取同一数据时,数据保持一致,即使其他事务对该数据进行了修改并提交。可以避免脏读和不可重复读,但可能会出现幻读问题。
  4. 串行化(Serializable):最高的隔离级别,事务串行执行,避免了所有并发问题,但性能较低。

在Java Web应用中,需要根据业务需求选择合适的事务隔离级别。例如,对于一些对数据一致性要求不高但对性能要求较高的场景,可以选择读已提交隔离级别;而对于涉及金融交易等对数据一致性要求极高的场景,可能需要选择可重复读或串行化隔离级别。

乐观锁与悲观锁

悲观锁

  1. 概念:悲观锁假设每次访问共享资源时都会发生冲突,因此在操作数据前先获取锁,以防止其他线程同时访问。前面提到的synchronized关键字和Lock接口实现类都属于悲观锁的范畴。
  2. 适用场景:适用于写操作频繁,且对数据一致性要求极高的场景,如银行转账等操作。

乐观锁

  1. 概念:乐观锁假设多数情况下访问共享资源不会发生冲突,因此不会在操作数据前获取锁。而是在更新数据时,检查数据在读取后是否被其他线程修改过。如果没有修改,则更新成功;否则,重试操作。
  2. 实现方式
    • 版本号机制:在数据库表中添加一个版本号字段。每次更新数据时,版本号加1。在更新操作前,先读取数据的版本号,更新时将版本号作为条件。如果版本号与读取时一致,则更新成功,同时版本号加1;否则,说明数据已被其他线程修改,需要重试。
-- 创建表
CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    price DECIMAL(10, 2),
    version INT
);

-- 更新操作示例
UPDATE products
SET price =?, version = version + 1
WHERE id =? AND version =?;
- **时间戳机制**:类似于版本号机制,使用时间戳字段记录数据的最后修改时间。在更新时检查时间戳是否与读取时一致。

3. 适用场景:适用于读操作频繁,写操作相对较少的场景,如电商的商品浏览等操作。由于乐观锁不需要在操作前获取锁,性能通常比悲观锁更好。

并发控制中的常见问题及解决方案

死锁

  1. 死锁的概念:死锁是指两个或多个线程相互等待对方释放锁,导致所有线程都无法继续执行的情况。例如,线程A持有锁L1并等待锁L2,而线程B持有锁L2并等待锁L1,就会形成死锁。
  2. 死锁的产生条件
    • 互斥条件:资源只能被一个线程占用。
    • 占有并等待条件:线程持有一个资源并等待获取另一个资源。
    • 不可剥夺条件:资源只能由持有它的线程主动释放,其他线程不能强行剥夺。
    • 循环等待条件:多个线程形成一个循环等待链,每个线程都在等待下一个线程持有的资源。
  3. 死锁的检测与预防
    • 死锁预防:通过破坏死锁产生的条件来预防死锁。例如,采用资源分配图算法,在分配资源前检查是否会形成死锁。或者按照一定的顺序获取锁,避免循环等待。
    • 死锁检测:定期检查系统中是否存在死锁。在Java中,可以通过ThreadMXBean来检测死锁。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public class DeadlockDetector {
    public static void main(String[] args) {
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
        if (deadlockedThreads != null) {
            for (long threadId : deadlockedThreads) {
                ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId);
                System.out.println("Deadlocked thread: " + threadInfo.getThreadName());
            }
        } else {
            System.out.println("No deadlock detected.");
        }
    }
}

活锁

  1. 活锁的概念:活锁是指线程虽然没有被阻塞,但由于相互之间不断重试,导致无法继续执行有效工作的情况。例如,两个线程都试图给对方让路,但每次让路的方向相同,导致一直在让路而无法前进。
  2. 解决方案:引入随机延迟或使用更智能的重试策略,避免线程之间总是采取相同的行动。

饥饿

  1. 饥饿的概念:饥饿是指一个或多个线程由于优先级低或被其他线程长期占用资源,导致长时间无法执行的情况。
  2. 解决方案:合理设置线程优先级,避免高优先级线程长期占用资源。或者采用公平锁机制,确保每个线程都有机会获取锁。

性能优化与并发控制的平衡

优化锁的使用

  1. 减小锁的粒度:将大的锁分解为多个小的锁,减少锁的竞争范围。例如,在一个包含多个数据项的对象中,可以为每个数据项设置单独的锁。
  2. 缩短锁的持有时间:尽量在锁内执行最少的代码,将不需要同步的操作移到锁外面。

并发编程模型

  1. 生产者 - 消费者模型:通过队列来解耦生产者和消费者的操作,生产者将数据放入队列,消费者从队列中取出数据进行处理。Java的BlockingQueue提供了很好的支持。
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class ProducerConsumerExample {
    private static final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(10);

    public static class Producer implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 20; i++) {
                try {
                    queue.put(i);
                    System.out.println("Produced: " + i);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static class Consumer implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    Integer item = queue.take();
                    System.out.println("Consumed: " + item);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread producerThread = new Thread(new Producer());
        Thread consumerThread = new Thread(new Consumer());
        producerThread.start();
        consumerThread.start();
    }
}
  1. 线程池:使用线程池可以减少线程创建和销毁的开销,提高系统性能。Java的ExecutorServiceThreadPoolExecutor提供了线程池的实现。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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

监控与调优

  1. 使用工具监控:如Java VisualVM、YourKit等工具可以帮助监控线程的状态、锁的竞争情况等,从而找出性能瓶颈。
  2. 性能调优:根据监控结果,调整并发控制策略、优化代码逻辑等,以达到性能和并发控制的平衡。

在Java Web应用开发中,并发控制是一个复杂但至关重要的领域。通过合理选择并发控制机制,能够在保证数据一致性的前提下,提高系统的并发处理能力和性能,为用户提供更高效、稳定的服务。