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

Java并发编程中的锁机制

2023-06-035.7k 阅读

Java并发编程中的锁机制

在Java并发编程领域,锁机制是控制多线程访问共享资源的关键手段。它确保在同一时刻只有一个线程能够访问特定的共享资源,从而避免数据竞争和不一致问题。

1. 锁的基本概念

在多线程环境下,多个线程可能同时尝试访问和修改共享资源。例如,多个线程对同一个银行账户进行取款操作,如果没有适当的控制,就可能导致账户余额出现错误。锁的作用就是在某一线程访问共享资源时,将其锁定,其他线程必须等待锁的释放才能访问该资源。

2. 内置锁(Monitor锁)

在Java中,每个对象都有一个内置锁(也称为Monitor锁)。当一个线程进入一个同步块(使用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) {
        if (balance >= amount) {
            balance -= amount;
        }
    }

    public synchronized double getBalance() {
        return balance;
    }
}

在上述代码中,depositwithdrawgetBalance方法都使用了synchronized关键字进行修饰。这意味着当一个线程调用其中任何一个方法时,它会获取BankAccount实例的内置锁。其他线程如果想调用这些同步方法,必须等待锁的释放。

锁的重入性:内置锁是可重入的。这意味着同一个线程可以多次获取同一个锁而不会造成死锁。例如:

public class ReentrantExample {
    public synchronized void method1() {
        System.out.println("Inside method1");
        method2();
    }

    public synchronized void method2() {
        System.out.println("Inside method2");
    }
}

method1中调用method2时,由于同一个线程已经持有了锁,所以可以再次获取锁进入method2方法。

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

从Java 5开始,引入了java.util.concurrent.locks.Lock接口及其实现类,如ReentrantLock。与内置锁相比,显式锁提供了更灵活的锁控制。

ReentrantLock示例

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

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

    public Counter() {
        this.count = 0;
    }

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

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

在上述代码中,通过ReentrantLocklock方法获取锁,在try块中执行对共享资源(count变量)的操作,最后在finally块中使用unlock方法释放锁。这样可以确保无论在操作过程中是否发生异常,锁都能被正确释放。

锁的公平性ReentrantLock支持公平锁和非公平锁。公平锁按照线程请求锁的顺序来分配锁,而非公平锁则允许线程在锁可用时立即尝试获取锁,可能会导致某些线程长时间等待。默认情况下,ReentrantLock是非公平锁,因为非公平锁在大多数情况下性能更高。可以通过构造函数来创建公平锁:

Lock fairLock = new ReentrantLock(true);

4. 读写锁(ReadWriteLock接口及其实现)

在很多场景下,对共享资源的读操作远远多于写操作。如果使用普通的锁,会导致读操作也被串行化,降低系统性能。ReadWriteLock接口提供了一种解决方案,它将锁分为读锁和写锁。

ReentrantReadWriteLock示例

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

public class SharedData {
    private int data;
    private ReadWriteLock lock = new ReentrantReadWriteLock();

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

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

在上述代码中,写操作(write方法)使用写锁,读操作(read方法)使用读锁。多个线程可以同时持有读锁进行读操作,但写锁在持有期间,其他线程无论是读还是写都必须等待。这样既保证了写操作的原子性和数据一致性,又提高了读操作的并发性能。

5. 锁的优化策略

减少锁的粒度:通过将大的锁范围缩小,只对关键的共享资源加锁,可以提高并发性能。例如,在一个包含多个独立数据项的对象中,可以为每个数据项单独加锁,而不是对整个对象加锁。

锁粗化:与减少锁粒度相反,当一系列连续的操作都对同一个对象加锁时,可以将这些操作的锁范围扩大,减少锁的获取和释放次数。例如:

public class StringAppender {
    private StringBuilder sb = new StringBuilder();

    public void appendStrings(String... strings) {
        synchronized (this) {
            for (String s : strings) {
                sb.append(s);
            }
        }
    }
}

在上述代码中,对整个appendStrings方法进行同步,而不是在每次append操作时都进行同步,减少了锁的开销。

锁消除:现代的Java编译器和JVM会在运行时检测到一些不可能存在竞争的锁,并将其消除。例如:

public class LockEliminationExample {
    public void nonSharedMethod() {
        StringBuffer sb = new StringBuffer();
        sb.append("Hello");
        sb.append(" World");
    }
}

nonSharedMethod中,StringBuffer是局部变量,不存在多线程竞争,编译器和JVM会自动消除对StringBuffer操作时隐含的锁。

锁自旋:当一个线程尝试获取锁时,如果锁正在被其他线程持有,该线程可以不立即进入阻塞状态,而是在一定时间内自旋,不断尝试获取锁。如果在自旋期间锁被释放,该线程就可以避免进入阻塞和唤醒的开销。自旋的时间通常由JVM参数控制,并且自旋只适用于锁被持有时间较短的情况,否则会浪费CPU资源。

6. 死锁问题

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

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

在上述代码中,thread1获取lock1后尝试获取lock2,而thread2获取lock2后尝试获取lock1,如果thread1thread2都成功获取了第一个锁,就会相互等待对方释放锁,从而导致死锁。

避免死锁的方法

  • 破坏死锁的四个必要条件
    • 互斥条件:有些资源本身就是互斥的,难以破坏。但在某些情况下,可以通过资源的替代方案来避免这种绝对的互斥。
    • 占有并等待条件:可以要求线程一次性获取所有需要的资源,而不是逐步获取。例如,在上面的死锁示例中,如果两个线程都先尝试获取lock1lock2,然后再进行操作,就可以避免死锁。
    • 不可剥夺条件:可以通过设置超时机制,如果一个线程获取锁的时间过长,可以剥夺它已持有的锁。在ReentrantLock中,可以使用tryLock方法设置超时时间。
    • 循环等待条件:可以对资源进行排序,线程按照固定的顺序获取资源。例如,规定所有线程先获取编号小的锁,再获取编号大的锁,这样可以避免循环等待。
  • 使用超时机制:在使用锁时,设置获取锁的超时时间。如果在规定时间内没有获取到锁,线程可以选择放弃或者执行其他操作。例如,ReentrantLocktryLock(long timeout, TimeUnit unit)方法可以在指定时间内尝试获取锁。

7. 条件变量(Condition接口)

Condition接口是Lock接口的一个补充,它提供了类似Object类的waitnotifynotifyAll方法的功能,但更加灵活。每个Lock对象可以创建多个Condition对象,每个Condition对象可以等待不同的条件。

示例代码

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

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

    public void produce(int value) throws InterruptedException {
        lock.lock();
        try {
            while (available) {
                notFull.await();
            }
            buffer = value;
            available = true;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public int consume() throws InterruptedException {
        lock.lock();
        try {
            while (!available) {
                notEmpty.await();
            }
            available = false;
            notFull.signal();
            return buffer;
        } finally {
            lock.unlock();
        }
    }
}

在上述代码中,ProducerConsumer类实现了一个简单的生产者 - 消费者模型。生产者线程调用produce方法,当缓冲区已满(availabletrue)时,调用notFull.await方法等待,直到缓冲区有空间(由消费者线程调用notFull.signal唤醒)。消费者线程调用consume方法,当缓冲区为空(availablefalse)时,调用notEmpty.await方法等待,直到缓冲区有数据(由生产者线程调用notEmpty.signal唤醒)。

8. 锁的性能分析与监控

在Java中,可以使用工具如jstack来分析线程的锁状态,找出可能存在的性能问题。jstack命令可以生成线程转储文件,其中包含每个线程的堆栈信息,包括线程持有和等待的锁。

例如,运行jstack <pid>命令(<pid>为Java进程的ID),可以得到类似如下的输出:

"Thread-1" #12 prio=5 os_prio=0 tid=0x00007f8a8001d800 nid=0x1234 waiting for monitor entry [0x00007f8a7f5fe000]
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.example.DeadlockExample$2.run(DeadlockExample.java:25)
        - waiting to lock <0x00000007d50011c8> (a java.lang.Object)
        - locked <0x00000007d50011d8> (a java.lang.Object)
        at java.lang.Thread.run(Thread.java:748)

从上述输出中,可以看到Thread - 1线程处于BLOCKED状态,正在等待获取<0x00000007d50011c8>对象的锁,并且已经持有<0x00000007d50011d8>对象的锁。通过分析这些信息,可以找出死锁或者锁竞争严重的线程,进而优化代码。

此外,Java 9引入了Java Flight Recorder(JFR),它可以在生产环境中收集详细的性能数据,包括锁的使用情况。通过分析JFR生成的记录文件,可以更深入地了解锁的性能瓶颈,如锁的持有时间、竞争程度等。

综上所述,Java并发编程中的锁机制是一个复杂而关键的领域。从基本的内置锁到功能更强大的显式锁,再到读写锁、条件变量等高级特性,以及锁的优化策略和死锁处理,开发者需要深入理解并合理运用这些知识,以编写高效、稳定的并发程序。通过性能分析和监控工具,能够及时发现并解决锁相关的性能问题,提升系统的整体性能。