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

Java并发编程中的锁机制与优化

2023-06-082.3k 阅读

Java并发编程中的锁机制基础

在Java并发编程中,锁机制是实现线程安全的关键手段。Java提供了多种锁机制,每种锁都有其特点和适用场景。

1. 内置锁(Monitor锁)

Java中的每个对象都可以作为一个锁,这种锁被称为内置锁或Monitor锁。当一个线程进入一个同步方法或同步块时,它会自动获取该对象的内置锁;当方法或块执行完毕,线程会自动释放锁。

public class SynchronizedExample {
    private int count = 0;

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

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

在上述代码中,incrementgetCount方法都被synchronized关键字修饰,这意味着当一个线程调用这些方法时,它会获取SynchronizedExample实例的内置锁。其他线程在该锁被持有期间无法进入这些同步方法,从而保证了count变量的线程安全。

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

ReentrantExample类中,outerMethod调用了innerMethod。由于两个方法都是同步的,并且属于同一个对象实例,同一个线程可以顺利进入innerMethod,这就是锁的可重入性。

显式锁(Lock接口)

除了内置锁,Java 5.0引入了java.util.concurrent.locks.Lock接口,它提供了比内置锁更灵活和强大的功能。

1. Lock接口的基本使用

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

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

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

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

LockExample类中,使用ReentrantLock实现了线程安全的计数器。与内置锁不同,显式锁需要手动调用lock()方法获取锁,并在finally块中调用unlock()方法释放锁,以确保锁一定会被释放。

2. 公平锁与非公平锁

ReentrantLock默认是非公平锁,它允许新到来的线程在锁可用时立即尝试获取锁,而不考虑等待队列中的其他线程。公平锁则会按照线程等待的顺序来分配锁。

// 创建公平锁
Lock fairLock = new ReentrantLock(true);
// 创建非公平锁
Lock unfairLock = new ReentrantLock();

公平锁虽然保证了线程获取锁的公平性,但由于频繁的线程切换,性能通常比非公平锁低。在大多数情况下,非公平锁更适合高并发场景。

读写锁(ReadWriteLock)

在很多应用场景中,读操作远远多于写操作。Java提供了ReadWriteLock接口来优化这种场景。

1. ReadWriteLock接口的实现

ReadWriteLock接口有一个实现类ReentrantReadWriteLock,它将锁分为读锁和写锁。允许多个线程同时获取读锁,但只允许一个线程获取写锁。

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

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

    public void read() {
        lock.readLock().lock();
        try {
            System.out.println("Reading data: " + data);
        } finally {
            lock.readLock().unlock();
        }
    }

    public void write(int newData) {
        lock.writeLock().lock();
        try {
            data = newData;
            System.out.println("Writing data: " + data);
        } finally {
            lock.writeLock().unlock();
        }
    }
}

ReadWriteLockExample类中,read方法获取读锁,多个线程可以同时调用read方法。而write方法获取写锁,在写锁被持有期间,其他线程无法获取读锁或写锁,从而保证了数据的一致性。

2. 读写锁的应用场景

读写锁适用于数据读取频繁、写入较少的场景,如缓存系统。在缓存系统中,多个线程可能同时读取缓存数据,而只有在数据更新时才需要获取写锁。

锁优化策略

在实际应用中,为了提高并发性能,需要对锁机制进行优化。

1. 减小锁的粒度

减小锁的粒度是指将大的锁分解为多个小的锁,从而降低锁的竞争程度。例如,在一个哈希表中,可以为每个哈希桶分配一个单独的锁。

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

public class FineGrainedLockingHashMap<K, V> {
    private static final int DEFAULT_BUCKET_COUNT = 16;
    private final Map<Integer, Lock> bucketLocks = new HashMap<>();
    private final Map<K, V> map = new HashMap<>();

    public FineGrainedLockingHashMap() {
        for (int i = 0; i < DEFAULT_BUCKET_COUNT; i++) {
            bucketLocks.put(i, new ReentrantLock());
        }
    }

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

    public V get(K key) {
        int bucketIndex = getBucketIndex(key);
        Lock lock = bucketLocks.get(bucketIndex);
        lock.lock();
        try {
            return map.get(key);
        } finally {
            lock.unlock();
        }
    }

    public void put(K key, V value) {
        int bucketIndex = getBucketIndex(key);
        Lock lock = bucketLocks.get(bucketIndex);
        lock.lock();
        try {
            map.put(key, value);
        } finally {
            lock.unlock();
        }
    }
}

FineGrainedLockingHashMap类中,通过为每个哈希桶分配一个单独的锁,不同哈希桶的操作可以并发进行,从而提高了并发性能。

2. 锁粗化

与减小锁粒度相反,锁粗化是指将多个连续的锁操作合并为一个大的锁操作。当一系列的操作都对同一个对象进行同步时,如果频繁地获取和释放锁,会增加系统开销。此时,可以将这些操作放在一个大的同步块中。

public class LockCoarseningExample {
    private final Object lock = new Object();

    public void performOperations() {
        synchronized (lock) {
            operation1();
            operation2();
            operation3();
        }
    }

    private void operation1() {
        // 具体操作1
    }

    private void operation2() {
        // 具体操作2
    }

    private void operation3() {
        // 具体操作3
    }
}

LockCoarseningExample类中,operation1operation2operation3都在同一个同步块中执行,减少了锁的获取和释放次数,提高了性能。

3. 锁消除

锁消除是指在编译时,JVM检测到某些代码块不可能存在竞争条件,从而自动消除这些代码块中的锁。例如:

public class LockEliminationExample {
    public void nonConcurrentMethod() {
        Object lock = new Object();
        synchronized (lock) {
            // 这里的操作不会被其他线程访问
            int result = 1 + 2;
        }
    }
}

nonConcurrentMethod方法中,lock对象只在该方法内部使用,不存在线程竞争,JVM在编译时会自动消除同步块中的锁,从而提高性能。

乐观锁与悲观锁

在并发编程中,根据对数据冲突的预测方式不同,锁可以分为乐观锁和悲观锁。

1. 悲观锁

悲观锁认为数据在任何时候都可能被其他线程修改,因此在获取数据时就会先获取锁,以防止其他线程修改数据。内置锁和Lock接口实现的锁都属于悲观锁。

2. 乐观锁

乐观锁则认为数据一般情况下不会发生冲突,只有在更新数据时才会检查是否有其他线程修改了数据。如果发现数据被修改,则重试操作。乐观锁通常通过版本号或CAS(Compare - And - Swap)操作来实现。

3. CAS操作

CAS是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。只有当V的值等于A时,CAS才会将V的值更新为B,否则不执行任何操作。Java的java.util.concurrent.atomic包中提供了基于CAS的原子类,如AtomicInteger

import java.util.concurrent.atomic.AtomicInteger;

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

    public void increment() {
        while (true) {
            int current = count.get();
            int next = current + 1;
            if (count.compareAndSet(current, next)) {
                break;
            }
        }
    }

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

CASExample类中,increment方法使用AtomicIntegercompareAndSet方法实现了线程安全的计数器。如果compareAndSet操作失败,说明在获取current值后,count的值被其他线程修改了,此时需要重新获取current值并再次尝试更新。

偏向锁、轻量级锁与重量级锁

在JDK 1.6中,为了提高锁的性能,引入了偏向锁和轻量级锁机制。

1. 偏向锁

偏向锁是为了在无竞争情况下减少锁获取的开销而设计的。当一个线程访问同步块并获取锁时,会在对象头中记录该线程的ID,以后该线程再次进入同步块时,不需要再次获取锁,直接进入即可。只有当有其他线程尝试获取该锁时,偏向锁才会被撤销。

2. 轻量级锁

轻量级锁是在竞争不是很激烈的情况下使用的。当一个线程获取轻量级锁时,它会在栈帧中创建一个锁记录(Lock Record),并将对象头中的Mark Word复制到锁记录中,然后尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果替换成功,该线程就获取了轻量级锁;如果失败,说明有其他线程竞争锁,轻量级锁会膨胀为重量级锁。

3. 重量级锁

重量级锁就是传统的内置锁,它通过操作系统的互斥量来实现线程同步。由于涉及到用户态和内核态的切换,重量级锁的开销较大。当轻量级锁竞争失败时,会升级为重量级锁。

锁的选择与性能分析

在实际应用中,选择合适的锁机制对于提高并发性能至关重要。

1. 根据场景选择锁

  • 内置锁:适用于简单的同步场景,代码简洁,自动获取和释放锁。但功能相对有限,如无法实现公平锁。
  • 显式锁(Lock接口):提供了更灵活的功能,如可中断的锁获取、公平锁等。适用于对锁功能要求较高的场景。
  • 读写锁:适用于读多写少的场景,能显著提高并发性能。
  • 乐观锁:适用于冲突较少的场景,通过减少锁的使用来提高性能。

2. 性能分析工具

在优化锁机制时,可以使用一些性能分析工具,如JProfiler、VisualVM等。这些工具可以帮助我们分析线程的运行状态、锁的竞争情况等,从而找出性能瓶颈并进行优化。

例如,使用JProfiler可以查看线程的CPU使用率、锁的持有时间等信息,通过分析这些数据,可以确定是否存在锁竞争过于激烈的情况,并针对性地进行优化,如调整锁的粒度、选择更合适的锁机制等。

死锁问题与避免

死锁是并发编程中常见的问题,它指的是两个或多个线程相互等待对方释放锁,从而导致程序无法继续执行。

1. 死锁示例

public class DeadlockExample {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1 acquired resource1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1 acquired resource2");
                }
            }
        });

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

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

DeadlockExample类中,thread1先获取resource1,然后尝试获取resource2thread2先获取resource2,然后尝试获取resource1。如果thread1thread2同时运行,就可能导致死锁。

2. 死锁的避免

  • 破坏死锁的四个必要条件:死锁的产生需要四个必要条件,即互斥、占有并等待、不可剥夺和循环等待。通过破坏其中任何一个条件,都可以避免死锁。例如,采用资源分配图算法可以破坏循环等待条件。
  • 按照相同顺序获取锁:在程序中,所有线程按照相同的顺序获取锁,可以避免循环等待。如在上述例子中,如果thread1thread2都先获取resource1,再获取resource2,就不会发生死锁。

总结

Java并发编程中的锁机制是一个复杂而重要的主题。从基础的内置锁到功能强大的显式锁,从适用于读多写少场景的读写锁到优化性能的乐观锁,以及偏向锁、轻量级锁和重量级锁的不同机制,每种锁都有其特点和适用场景。在实际应用中,我们需要根据具体的业务需求和性能要求,选择合适的锁机制,并通过减小锁粒度、锁粗化、锁消除等优化策略来提高并发性能。同时,要注意避免死锁等问题,确保程序的正确性和稳定性。通过深入理解和合理运用锁机制,我们能够编写出高效、可靠的并发程序。