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

Java并发控制与锁机制

2022-12-055.2k 阅读

Java并发控制的重要性

在当今多核处理器和多线程编程盛行的时代,Java并发控制是确保程序正确性和性能的关键因素。随着应用程序规模和复杂性的增加,多个线程同时访问和修改共享资源的情况愈发常见。如果没有适当的并发控制,就可能出现数据竞争、线程安全问题,导致程序产生不可预测的结果。

例如,考虑一个银行转账的场景,假设两个线程同时对同一个账户进行取款和存款操作。如果没有并发控制,可能会出现账户余额计算错误的情况。如下代码示例展示了没有并发控制时可能出现的问题:

public class BankAccount {
    private double balance;

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

    public void deposit(double amount) {
        balance += amount;
    }

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

    public double getBalance() {
        return balance;
    }
}

public class TransferThread extends Thread {
    private BankAccount fromAccount;
    private BankAccount toAccount;
    private double amount;

    public TransferThread(BankAccount from, BankAccount to, double amt) {
        this.fromAccount = from;
        this.toAccount = to;
        this.amount = amt;
    }

    @Override
    public void run() {
        fromAccount.withdraw(amount);
        toAccount.deposit(amount);
    }
}

public class Main {
    public static void main(String[] args) {
        BankAccount account1 = new BankAccount(1000);
        BankAccount account2 = new BankAccount(500);

        TransferThread thread1 = new TransferThread(account1, account2, 100);
        TransferThread thread2 = new TransferThread(account2, account1, 200);

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Account 1 balance: " + account1.getBalance());
        System.out.println("Account 2 balance: " + account2.getBalance());
    }
}

在上述代码中,由于BankAccount类的depositwithdraw方法没有任何并发控制,当多个线程同时调用这些方法时,就可能导致账户余额计算错误。这凸显了Java并发控制的重要性。

锁机制的引入

为了解决并发控制问题,Java引入了锁机制。锁可以理解为一种同步工具,它允许线程在访问共享资源之前获取锁,当访问完成后释放锁。这样,同一时间只有一个线程能够持有锁并访问共享资源,从而避免了数据竞争。

内置锁(Monitor锁)

Java中最基本的锁类型是内置锁,也称为Monitor锁。每个Java对象都可以作为一个内置锁。当一个线程进入一个同步代码块(使用synchronized关键字修饰的代码块)时,它会自动获取对象的内置锁,当代码块执行完毕或者抛出异常时,锁会自动释放。

以下是使用内置锁解决上述银行转账问题的代码示例:

public class BankAccount {
    private double balance;

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

    public synchronized void deposit(double amount) {
        balance += amount;
    }

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

    public double getBalance() {
        return balance;
    }
}

public class TransferThread extends Thread {
    private BankAccount fromAccount;
    private BankAccount toAccount;
    private double amount;

    public TransferThread(BankAccount from, BankAccount to, double amt) {
        this.fromAccount = from;
        this.toAccount = to;
        this.amount = amt;
    }

    @Override
    public void run() {
        synchronized (fromAccount) {
            synchronized (toAccount) {
                fromAccount.withdraw(amount);
                toAccount.deposit(amount);
            }
        }
    }
}

public class Main {
    public static void main(String[] args) {
        BankAccount account1 = new BankAccount(1000);
        BankAccount account2 = new BankAccount(500);

        TransferThread thread1 = new TransferThread(account1, account2, 100);
        TransferThread thread2 = new TransferThread(account2, account1, 200);

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Account 1 balance: " + account1.getBalance());
        System.out.println("Account 2 balance: " + account2.getBalance());
    }
}

在上述代码中,BankAccount类的depositwithdraw方法使用了synchronized关键字,这使得每次只有一个线程能够访问这些方法,从而保证了线程安全。同时,在TransferThreadrun方法中,通过synchronized块对fromAccounttoAccount进行同步,避免了死锁的发生。

锁的特性

  1. 互斥性:内置锁具有互斥性,即同一时间只有一个线程能够持有锁。这确保了在同一时间只有一个线程能够访问被锁保护的共享资源,从而避免数据竞争。
  2. 可重入性:内置锁是可重入的。这意味着同一个线程可以多次获取同一个锁,而不会造成死锁。例如,一个递归方法在每次递归调用时都可以获取锁,而不会出现问题。如下代码展示了可重入性:
public class ReentrantExample {
    public synchronized void outerMethod() {
        System.out.println("Entering outer method");
        innerMethod();
    }

    public synchronized void innerMethod() {
        System.out.println("Entering inner method");
    }
}

public class Main {
    public static void main(String[] args) {
        ReentrantExample example = new ReentrantExample();
        example.outerMethod();
    }
}

在上述代码中,outerMethodinnerMethod都使用了synchronized关键字,当outerMethod调用innerMethod时,由于内置锁的可重入性,同一个线程可以再次获取锁,程序能够正常执行。

锁的种类与应用场景

除了内置锁,Java还提供了其他类型的锁,每种锁都有其独特的特性和应用场景。

重入锁(ReentrantLock)

ReentrantLock是Java 5.0引入的一种可重入的互斥锁,它提供了比内置锁更灵活的锁控制。与内置锁不同,ReentrantLock需要手动获取和释放锁。

以下是使用ReentrantLock解决银行转账问题的代码示例:

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

public class BankAccount {
    private double balance;
    private Lock lock = new ReentrantLock();

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

    public void deposit(double amount) {
        lock.lock();
        try {
            balance += amount;
        } finally {
            lock.unlock();
        }
    }

    public void withdraw(double amount) {
        lock.lock();
        try {
            balance -= amount;
        } finally {
            lock.unlock();
        }
    }

    public double getBalance() {
        return balance;
    }
}

public class TransferThread extends Thread {
    private BankAccount fromAccount;
    private BankAccount toAccount;
    private double amount;

    public TransferThread(BankAccount from, BankAccount to, double amt) {
        this.fromAccount = from;
        this.toAccount = to;
        this.amount = amt;
    }

    @Override
    public void run() {
        fromAccount.lock.lock();
        try {
            toAccount.lock.lock();
            try {
                fromAccount.withdraw(amount);
                toAccount.deposit(amount);
            } finally {
                toAccount.lock.unlock();
            }
        } finally {
            fromAccount.lock.unlock();
        }
    }
}

public class Main {
    public static void main(String[] args) {
        BankAccount account1 = new BankAccount(1000);
        BankAccount account2 = new BankAccount(500);

        TransferThread thread1 = new TransferThread(account1, account2, 100);
        TransferThread thread2 = new TransferThread(account2, account1, 200);

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Account 1 balance: " + account1.getBalance());
        System.out.println("Account 2 balance: " + account2.getBalance());
    }
}

在上述代码中,BankAccount类使用ReentrantLock来保护共享资源。通过lock方法获取锁,unlock方法释放锁,并且在try - finally块中确保锁一定被释放,以避免资源泄漏。

ReentrantLock的优势:

  1. 公平性选择ReentrantLock可以通过构造函数设置为公平锁或非公平锁。公平锁按照请求锁的顺序来分配锁,而非公平锁则允许线程在锁可用时立即获取锁,可能会导致某些线程长时间等待。在高并发场景下,非公平锁通常具有更好的性能。
  2. 锁中断ReentrantLock提供了lockInterruptibly方法,允许线程在获取锁的过程中响应中断。这在内置锁中是无法实现的。例如,在一个线程等待锁的过程中,如果另一个线程发出中断信号,使用lockInterruptibly方法的线程可以响应中断并停止等待。

读写锁(ReadWriteLock)

ReadWriteLock允许多个线程同时进行读操作,但只允许一个线程进行写操作。这在一些读多写少的场景中非常有用,可以提高并发性能。

以下是一个简单的使用ReadWriteLock的代码示例:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cache {
    private final Map<String, Object> cache = new HashMap<>();
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public Object get(String key) {
        lock.readLock().lock();
        try {
            return cache.get(key);
        } finally {
            lock.readLock().unlock();
        }
    }

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

public class Main {
    public static void main(String[] args) {
        Cache cache = new Cache();

        Thread readThread1 = new Thread(() -> {
            System.out.println("Reading value: " + cache.get("key1"));
        });

        Thread readThread2 = new Thread(() -> {
            System.out.println("Reading value: " + cache.get("key2"));
        });

        Thread writeThread = new Thread(() -> {
            cache.put("key1", "value1");
        });

        readThread1.start();
        readThread2.start();
        writeThread.start();

        try {
            readThread1.join();
            readThread2.join();
            writeThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,Cache类使用ReadWriteLock来控制对缓存的读写操作。读操作使用读锁,允许多个线程同时读取缓存,写操作使用写锁,确保在写操作时没有其他线程可以读写缓存,从而保证数据一致性。

死锁问题及避免

死锁是并发编程中常见的问题,当两个或多个线程相互等待对方释放锁时,就会发生死锁。

死锁示例

以下是一个简单的死锁示例:

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 acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1 acquired lock2");
                }
            }
        });

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

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,thread1先获取lock1,然后尝试获取lock2,而thread2先获取lock2,然后尝试获取lock1。如果thread1获取lock1后,thread2获取lock2,两个线程就会相互等待对方释放锁,从而导致死锁。

死锁的避免

  1. 破坏死锁的四个必要条件

    • 互斥条件:在某些情况下,可以通过使用无锁数据结构(如ConcurrentHashMap)来避免互斥锁的使用,从而破坏互斥条件。但在大多数需要保护共享资源的场景下,互斥条件难以完全消除。
    • 占有并等待条件:可以让线程一次性获取所有需要的锁,而不是逐步获取。例如,在银行转账的场景中,可以规定所有线程必须按照相同的顺序获取账户锁,从而避免占有并等待的情况。
    • 不可剥夺条件:在Java中,锁一旦被线程获取,其他线程无法强制剥夺。但可以通过使用可中断的锁(如ReentrantLocklockInterruptibly方法),当一个线程等待锁的时间过长时,可以通过中断来释放已经获取的锁。
    • 循环等待条件:通过对锁进行排序,确保所有线程按照相同的顺序获取锁,从而避免循环等待。例如,在多个线程需要获取多个锁的情况下,可以为每个锁分配一个唯一的标识符,线程按照标识符的升序或降序来获取锁。
  2. 使用定时锁ReentrantLock提供了tryLock方法,可以在指定的时间内尝试获取锁。如果在指定时间内无法获取锁,线程可以放弃获取锁并执行其他操作,从而避免死锁。如下代码展示了如何使用tryLock方法:

import java.util.concurrent.locks.ReentrantLock;

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

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            if (lock1.tryLock()) {
                try {
                    if (lock2.tryLock()) {
                        try {
                            System.out.println("Thread 1 acquired both locks");
                        } finally {
                            lock2.unlock();
                        }
                    } else {
                        System.out.println("Thread 1 could not acquire lock2");
                    }
                } finally {
                    lock1.unlock();
                }
            } else {
                System.out.println("Thread 1 could not acquire lock1");
            }
        });

        Thread thread2 = new Thread(() -> {
            if (lock2.tryLock()) {
                try {
                    if (lock1.tryLock()) {
                        try {
                            System.out.println("Thread 2 acquired both locks");
                        } finally {
                            lock1.unlock();
                        }
                    } else {
                        System.out.println("Thread 2 could not acquire lock1");
                    }
                } finally {
                    lock2.unlock();
                }
            } else {
                System.out.println("Thread 2 could not acquire lock2");
            }
        });

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,thread1thread2都使用tryLock方法尝试获取锁。如果在尝试获取第二个锁时失败,线程会放弃并输出相应的信息,从而避免了死锁。

锁优化与性能调优

在并发编程中,锁的性能对程序的整体性能有着重要影响。以下是一些锁优化和性能调优的方法。

减小锁的粒度

锁的粒度指的是锁所保护的代码块的大小。减小锁的粒度可以提高并发性能,因为它允许更多的线程同时访问不同部分的共享资源。

例如,在一个多线程访问的哈希表中,如果使用一个大锁来保护整个哈希表,那么每次只有一个线程能够访问哈希表,即使不同线程访问的是哈希表的不同部分。可以通过将哈希表分成多个桶(bucket),每个桶使用一个单独的锁来保护,这样不同线程可以同时访问不同的桶,提高并发性能。

以下是一个简单的示例,展示了如何通过减小锁的粒度来优化性能:

import java.util.concurrent.locks.ReentrantLock;

public class FineGrainedLockingHashMap<K, V> {
    private static final int DEFAULT_BUCKET_COUNT = 16;
    private final Bucket[] buckets;

    public FineGrainedLockingHashMap() {
        this.buckets = new Bucket[DEFAULT_BUCKET_COUNT];
        for (int i = 0; i < DEFAULT_BUCKET_COUNT; i++) {
            buckets[i] = new Bucket();
        }
    }

    private int hash(K key) {
        return Math.abs(key.hashCode()) % DEFAULT_BUCKET_COUNT;
    }

    public V get(K key) {
        int index = hash(key);
        buckets[index].lock.lock();
        try {
            return buckets[index].map.get(key);
        } finally {
            buckets[index].lock.unlock();
        }
    }

    public void put(K key, V value) {
        int index = hash(key);
        buckets[index].lock.lock();
        try {
            buckets[index].map.put(key, value);
        } finally {
            buckets[index].lock.unlock();
        }
    }

    private static class Bucket {
        private final java.util.HashMap<Object, Object> map = new java.util.HashMap<>();
        private final ReentrantLock lock = new ReentrantLock();
    }
}

public class Main {
    public static void main(String[] args) {
        FineGrainedLockingHashMap<String, Integer> map = new FineGrainedLockingHashMap<>();

        Thread thread1 = new Thread(() -> {
            map.put("key1", 1);
        });

        Thread thread2 = new Thread(() -> {
            map.put("key2", 2);
        });

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Value for key1: " + map.get("key1"));
        System.out.println("Value for key2: " + map.get("key2"));
    }
}

在上述代码中,FineGrainedLockingHashMap将哈希表分成了16个桶,每个桶使用一个ReentrantLock来保护。这样,不同线程可以同时访问不同的桶,提高了并发性能。

锁粗化

与减小锁的粒度相反,锁粗化是指将多个连续的锁操作合并成一个较大的锁操作。当一系列的操作都需要获取同一个锁时,如果频繁地获取和释放锁,会增加性能开销。通过锁粗化,可以减少锁的获取和释放次数,提高性能。

例如,在一个循环中多次访问共享资源并获取锁的场景中,可以将锁的获取移到循环外部,从而减少锁的获取和释放次数。

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

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            synchronized (lock) {
                for (int i = 0; i < 1000000; i++) {
                    count++;
                }
            }
        });

        thread.start();

        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final count: " + count);
    }
}

在上述代码中,将锁操作放在循环外部,避免了在每次循环时都获取和释放锁,提高了性能。

使用无锁数据结构

在某些场景下,使用无锁数据结构可以避免锁带来的性能开销。Java提供了一些无锁数据结构,如ConcurrentHashMapConcurrentLinkedQueue等。这些数据结构使用了一些特殊的算法(如CAS - Compare - And - Swap)来实现线程安全,而不需要使用锁。

例如,ConcurrentHashMap在高并发读多写少的场景下具有很好的性能,因为它采用了分段锁的机制,允许多个线程同时进行读操作,并且在写操作时只对需要修改的段进行加锁,而不是对整个哈希表加锁。

import java.util.concurrent.ConcurrentHashMap;

public class Main {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        Thread thread1 = new Thread(() -> {
            map.put("key1", 1);
        });

        Thread thread2 = new Thread(() -> {
            map.put("key2", 2);
        });

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Value for key1: " + map.get("key1"));
        System.out.println("Value for key2: " + map.get("key2"));
    }
}

在上述代码中,ConcurrentHashMap能够在多线程环境下高效地进行读写操作,而不需要像传统的HashMap那样使用锁来保护整个数据结构。

总结锁机制在并发控制中的作用

锁机制在Java并发控制中扮演着至关重要的角色。它通过提供互斥访问的能力,确保了共享资源在多线程环境下的一致性和完整性。从最基本的内置锁到功能更强大的ReentrantLockReadWriteLock,不同类型的锁满足了各种不同的并发场景需求。

同时,我们也了解到死锁是并发编程中需要重点关注的问题,通过破坏死锁的必要条件和使用定时锁等方法,可以有效地避免死锁的发生。在性能优化方面,减小锁的粒度、锁粗化以及使用无锁数据结构等技术,可以显著提高并发程序的性能。

在实际的Java开发中,深入理解和合理运用锁机制是编写高效、可靠的并发程序的关键。开发人员需要根据具体的业务需求和场景,选择合适的锁类型和优化策略,以实现最佳的并发性能和程序正确性。

在未来的并发编程发展中,随着硬件技术的不断进步和新的并发模型的出现,锁机制也可能会不断演进和优化,为开发人员提供更强大、更高效的并发控制工具。