Java 锁高阶玩法大揭秘:从入门到避坑

137 阅读7分钟

嘿,各位 Java 小伙伴们!多线程编程里,Java 锁可是个关键角色,用好了它,代码性能一路狂飙,用不好,就等着掉进各种 “坑” 里,哭都找不着调。今天,我就来给大伙唠唠 Java 锁的高阶用法,再帮大家把那些容易踩的坑都给填平咯!

一、Java 锁的高阶使用

(一)读写锁:读与写的和谐共处

想象一下,多线程环境就像一个超热闹的图书馆。一群读者都在安静地翻书查阅资料(读操作),突然,有工作人员来整理书架(写操作)。要是没有个靠谱的管理办法,那场面不得乱成一锅粥?读写锁就像一位超有经验的图书馆管理员,它允许好多读者同时看书(多个线程同时读),毕竟读又不会改数据,所以多个线程同时读也不会整出数据不一致的幺蛾子。可一旦工作人员开始整理书架(写操作),所有读者都得乖乖停下,等着整理完再说。为啥呢?因为写操作会改数据啊,要是写的时候还有其他线程在那读或者写,数据不就乱套了嘛。这么一来,读写锁既保证了阅读效率,又维护了图书馆的秩序,让数据稳稳当当的。

在 Java 的世界里,ReentrantReadWriteLock就是这位神奇的管理员。用起来也简单得很,瞅下面这段代码:

import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
    private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private static int sharedData = 0;
    public static void main(String[] args) {
        // 开启多个读线程
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                lock.readLock().lock();
                try {
                    System.out.println(Thread.currentThread().getName() + " 开始读取数据,数据为:" + sharedData);
                    // 模拟一些读取操作
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                } finally {
                    lock.readLock().unlock();
                }
            }, "读线程" + i).start();
        }
        // 开启一个写线程
        new Thread(() -> {
            lock.writeLock().lock();
            try {
                System.out.println(Thread.currentThread().getName() + " 开始写入数据");
                sharedData++;
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 写入数据完成,数据更新为:" + sharedData);
            } finally {
                lock.writeLock().unlock();
            }
        }, "写线程").start();
    }
}

在这个例子里,多个读线程可以同时获取读锁读取sharedData,而写线程获取写锁时,其他读线程和写线程都得等着,直到写线程释放写锁。实际应用中,像缓存系统就很适合用读写锁。大量线程同时读缓存数据,只有在缓存过期或要更新时才有写操作,用读写锁能大大提升系统的并发性能。

(二)Condition:锁的贴心小帮手

Condition 就像是锁的超级得力助手,它是基于 AQS(AbstractQueuedSynchronizer)框架搞出来的,能让线程在特定条件下暂停和恢复。打个比方,你正在开发一款超火的游戏,玩家们都摩拳擦掌、迫不及待地想进游戏大干一场,但游戏得先加载完呀。这时候,Condition 就闪亮登场啦,它能让玩家线程先等着,等游戏加载完了,再把这些线程唤醒,让玩家们尽情嗨玩。

看下面的代码示例:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionExample {
    private static final Lock lock = new ReentrantLock();
    private static final Condition condition = lock.newCondition();
    private static boolean gameLoaded = false;
    public static void main(String[] args) {
        // 玩家线程
        new Thread(() -> {
            lock.lock();
            try {
                while (!gameLoaded) {
                    System.out.println(Thread.currentThread().getName() + " 等待游戏加载...");
                    condition.await();
                }
                System.out.println(Thread.currentThread().getName() + " 游戏加载完成,开始游戏!");
                // 模拟玩家进入游戏后的操作
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "玩家线程").start();
        // 游戏加载线程
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " 开始加载游戏...");
                // 模拟游戏加载过程
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                gameLoaded = true;
                System.out.println(Thread.currentThread().getName() + " 游戏加载完成,唤醒玩家线程");
                condition.signalAll();
            } finally {
                lock.unlock();
            }
        }, "游戏加载线程").start();
    }
}

除了游戏加载这个场景,在生产者 - 消费者模型里,Condition 也用得特别多。生产者线程生产完数据,就可以通过 Condition 唤醒等着的消费者线程;消费者线程消费完数据,也能通过 Condition 唤醒等着的生产者线程,这样就能实现高效的线程协作啦。

二、避坑指南

(一)死锁:千万别陷入锁的 “死亡循环”

死锁这玩意儿,就好比两个小朋友抢玩具,谁都死死抓住不放手,最后谁都玩不成。在多线程编程里,如果两个或多个线程互相拿着对方需要的锁,就掉进死锁这个坑里了。比如说,线程 A 紧紧攥着锁 1,眼巴巴地瞅着锁 2;线程 B 呢,则牢牢抓着锁 2,心心念念地盼着锁 1。这下可好,两个线程都动弹不了,程序就跟被施了定身咒似的,彻底僵住。

看下面这个具体的代码示例,演示死锁是怎么发生的:

public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lock1) {
                System.out.println(Thread.currentThread().getName() + " 获取了 lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 尝试获取 lock2");
                synchronized (lock2) {
                    System.out.println(Thread.currentThread().getName() + " 获取了 lock2");
                }
            }
        }, "线程A").start();
        new Thread(() -> {
            synchronized (lock2) {
                System.out.println(Thread.currentThread().getName() + " 获取了 lock2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 尝试获取 lock1");
                synchronized (lock1) {
                    System.out.println(Thread.currentThread().getName() + " 获取了 lock1");
                }
            }
        }, "线程B").start();
    }
}

运行这段代码,就会发现线程 A 和线程 B 陷入了死锁。那怎么避免死锁呢?记住一个简单的原则:按照固定的顺序获取锁。就好比小朋友们排队领玩具,大家都守规矩,就不会乱套。在实际编程中,给所有要获取锁的资源定个统一的顺序,每个线程都按这个顺序来获取锁,就能有效避免死锁。另外,还可以用定时锁,获取锁的时候设置个超时时间,要是在规定时间里没拿到锁,就放弃获取,做点别的处理,这也能在一定程度上避免死锁。

(二)锁的粒度:把握好锁的 “尺寸”

锁的粒度就像是你盖房子选的砖块大小。要是锁的范围太大(大砖块),就会把好多没必要的代码也圈进来,程序性能那不得大打折扣,就像用大砖头盖小房子,又浪费又不实用。比如说,有个方法里有一堆只读操作,要是给整个方法加锁,那就算是不需要同步的读操作,也得因为锁在那干等着,并发性能直接就拉胯了。

反过来,要是锁的范围太小(小砖块),又可能保护不了关键数据,就像用小砖头盖大房子,轻轻一推就倒。举个例子,对一个包含好几个相关数据项的对象进行操作时,要是只对其中一个数据项加锁,其他数据项的操作没同步,就可能导致数据不一致。

所以啊,得根据实际情况,精准地选好合适的锁粒度。比如说统计一个班级学生的成绩,要是给每个学生的成绩统计都单独加锁,就跟用小砖块一块一块慢慢垒似的,效率低得不行,这就是锁得太窄;但要是整个班级的成绩统计就用一把大锁,就像用一块超大的砖头把整个班级都罩住,所有操作都得排队等着,这就是锁得太宽。正确的做法是,根据数据的访问频率和关联性,合理划分锁的范围,找到最合适的 “砖块”。比如,可以把成绩相关的操作按小组或者学科分组加锁,这样既能保证数据一致,又能提升并发性能。看下面这个简单的示例:

public class LockGranularityExample {
    private static final Object groupLock1 = new Object();
    private static final Object groupLock2 = new Object();
    public static void main(String[] args) {
        // 假设这里有两组学生成绩统计
        new Thread(() -> {
            synchronized (groupLock1) {
                System.out.println(Thread.currentThread().getName() + " 开始统计第一组学生成绩");
                // 模拟统计第一组学生成绩的操作
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 完成第一组学生成绩统计");
            }
        }, "统计线程1").start();
        new Thread(() -> {
            synchronized (groupLock2) {
                System.out.println(Thread.currentThread().getName() + " 开始统计第二组学生成绩");
                // 模拟统计第二组学生成绩的操作
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 完成第二组学生成绩统计");
            }
        }, "统计线程2").start();
    }
}

在这个例子里,通过分组加锁,不同组的成绩统计可以并发进行,提高了效率。

三、总结

Java 锁的高阶应用就像一场超级刺激的冒险,用好了,程序性能直接起飞;要是不小心,就会掉进各种坑里,摔得鼻青脸肿。希望通过这篇文章,大家都能把 Java 锁的高阶玩法拿捏得死死的,巧妙避开那些坑,在多线程编程的道路上一路狂飙!要是你还有啥疑问或者想法,别犹豫,赶紧在评论区留言,咱们一起唠唠,共同进步!