并发编程之锁:守护数据的魔法

2023/10/11 21:03:07

本文主要是介绍并发编程之锁:守护数据的魔法,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

大家好,我是大圣,很高兴又和大家见面。

回忆起我们上次的聚焦,我们一同深挖了并发、并行和高并发这三位巨头和 多线程和异步编程背后的魔法,它们在现代计算的巨大舞台上,就如同战场中的英雄,确保了每一个请求都能得到迅速且准确的响应。

并发、并行和高并发 这三个知识点在公众号的《并发、并行、高并发 新认识》 这篇文章讲了,多线程和异步编程背后的魔法也在公众号 《多线程、异步编程、并发读写 新认识》在这篇文章讲了,如果大家对这些概念不明白的可以去看一下。

今天,我们将聚焦于当多个线程试图同时访问相同的数据资源时,可能出现的挑战和风险。你可能会问:“大圣,为什么我们需要考虑这些问题呢?”

答案很简单:为了数据的一致性和完整性。因此,我将为大家揭晓两位并发世界中的明星中的一位:锁,它如何作为双刃之剑,既保护数据,又确保系统的高效运行。

那么,不再赘述,让我们一起深入这个充满魔法的并发之旅吧!

锁 (Locking)

举例理解锁

想象一下你去了一家热闹的咖啡店,店里只有一台自动咖啡机。当一位顾客正在使用咖啡机时,其他的顾客必须等待,直到当前的顾客制作完自己的咖啡并离开。这时,咖啡机就像一个被“锁定”的资源,同一时间只允许一个顾客使用,确保每个人都能完整、正确地完成咖啡制作过程。

这就是我们生活中常见的“排他”的例子,而在计算领域,这种机制称为“锁”。

专业定义

在计算机科学中,锁是一种保护共享资源的机制,确保在任何给定时刻,只有一个线程或进程可以访问或修改该资源。它防止当多个线程或进程试图同时访问某个特定资源时出现的数据竞争或冲突。

通过使用锁,系统可以确保资源的完整性和一致性,避免由于并发访问导致的不一致状态。

锁解决了哪些问题

1.数据竞态

当两个或多个线程同时访问共享数据,并至少有一个线程尝试修改数据时,数据竞态就可能发生。锁确保了在同一时间,只有一个线程可以修改资源,从而防止数据被不同的线程不一致地修改。

复现数据竞争情况:
public class DataRaceExample {
    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter++;
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter--;
        });

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

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

这个代码可能不会每次都打印0,因为两个线程可能会同时修改counter,造成数据竞态。

2.数据不一致性

无锁的并发访问可能导致数据不一致。例如,如果两个线程同时读取一个变量,然后都尝试更新它,其中一个线程的更新可能会被另一个线程的更新所覆盖。锁确保数据的修改是原子的,即在一个线程修改数据的过程中,其他线程不能访问它。

复现数据不一致性情况:

与数据竞态的例子类似,两个线程尝试修改同一个数据,可能导致其中一个线程的修改被覆盖。

3.死锁

虽然锁是为了解决并发问题而引入的,但它自身也可能引发问题。当两个或多个线程互相等待对方释放资源时,就会出现死锁。这是锁管理的复杂性,需要特别的策略来避免。

复现死锁情况:
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("Thread1 acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignore) {
                }
                synchronized (lock2) {
                    System.out.println("Thread1 acquired lock2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread2 acquired lock2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignore) {
                }
                synchronized (lock1) {
                    System.out.println("Thread2 acquired lock1");
                }
            }
        });

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

这个例子中,两个线程尝试按不同的顺序获取两把锁,可能导致死锁。

4.资源访问的顺序性

在某些情况下,对共享资源的访问需要按照特定的顺序进行。锁可以确保这种顺序性,使得资源的访问和修改按预期的顺序执行。

复现资源访问的无序性情况:
public class OrderAccessIssue {

    private static int value = 0;

    public static void increment() {
        int temp = value;
        try {
            Thread.sleep(50);  //模拟一些数据处理
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        value = temp + 1;
    }

    public static int getValue() {
        return value;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> increment());
        Thread thread2 = new Thread(() -> increment());

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

        thread1.join();
        thread2.join();

        System.out.println("Final value: " + getValue());  //期望的是2,但可能得到1
    }
}

在这个示例中,increment方法故意添加了一个小的延迟,使得两个线程可能都读取同一个value值,导致最终的结果可能不是期望的2,而是1。这是因为两个线程几乎同时读取了value的旧值,然后都对它进行了加1的操作。

这种情况清楚地展示了,当没有适当的锁来控制资源访问的顺序时,可能会导致不可预测的结果。

5.避免过度访问:

在高并发环境中,过多的线程同时访问某个资源可能会导致资源过载或系统崩溃。锁可以限制对资源的并发访问,从而避免这种情况。

复现过度访问情况:
public class OverloadedServer {
    private int requestCount = 0;

    public void handleRequest() {
        requestCount++;
        System.out.println("Handling request " + requestCount + " by " + Thread.currentThread().getName());
        try {
            // 模拟处理请求,增加一些延迟
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        requestCount--;
    }

    public static void main(String[] args) {
        OverloadedServer server = new OverloadedServer();

        // 启动100个线程尝试并发访问服务器
        for (int i = 0; i < 100; i++) {
            new Thread(() -> server.handleRequest()).start();
        }
    }
}

当你运行这个代码时,你会发现“Handling request”的输出混乱,并且requestCount的计数可能会出现超出预期的数字,这是因为多个线程在没有同步的情况下对requestCount进行了修改,这就导致了所谓的“过度访问”。

这个例子展示了如果没有适当的锁来限制或同步访问,多线程访问共享资源可能会导致不可预期的结果和性能问题。

锁是如何解决这些问题的?

1.数据竞态

当两个或多个线程同时访问共享数据,并至少有一个线程尝试修改数据时,数据竞态就可能发生。锁确保了在同一时间,只有一个线程可以修改资源,从而防止数据被不同的线程不一致地修改。

解决数据竞争实现:

为了解决上述代码中的数据竞争问题,我们可以使用Java的synchronized关键字或者使用ReentrantLock。我会给出使用synchronized的方法。

public class DataRaceExample {
    private static int counter = 0;
    private static final Object lock = new Object();  // 创建一个锁对象

    public static void increment() {
        synchronized(lock) {
            counter++;
        }
    }

    public static void decrement() {
        synchronized(lock) {
            counter--;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) increment();
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) decrement();
        });

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

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

通过在increment和decrement方法中使用synchronized块,并且它们都使用同一个锁对象,我们确保了在任何时候只有一个线程可以修改counter,从而避免了数据竞争。

2.数据不一致性

无锁的并发访问可能导致数据不一致。例如,如果两个线程同时读取一个变量,然后都尝试更新它,其中一个线程的更新可能会被另一个线程的更新所覆盖。锁确保数据的修改是原子的,即在一个线程修改数据的过程中,其他线程不能访问它。

解决数据不一致:

与数据竞态的例子类似,见数据竞争的解决思路与代码

3.死锁

虽然锁是为了解决并发问题而引入的,但它自身也可能引发问题。当两个或多个线程互相等待对方释放资源时,就会出现死锁。这是锁管理的复杂性,需要特别的策略来避免。

解决死锁情况:

死锁是由于两个线程尝试以不同的顺序获取相同的锁而导致的。为避免死锁,我们可以确保所有线程都按照相同的顺序来获取锁。

以下是避免死锁的方法:

1)确定锁的顺序:确定一个全局的锁获取顺序。例如,总是先获取lock1,然后获取lock2。

2)尝试获取锁:当尝试获取锁时,可以使用ReentrantLock的tryLock方法,它尝试获取锁但不会无限期地等待。如果不能获取所有需要的锁,就释放已经获得的锁并重试或者放弃。

我们使用第一个方法,确保两个线程都按照相同的顺序获取锁:

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("Thread1 acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignore) {
                }
                synchronized (lock2) {
                    System.out.println("Thread1 acquired lock2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock1) {  // Change the order to lock1 first
                System.out.println("Thread2 acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignore) {
                }
                synchronized (lock2) {  // Then lock2
                    System.out.println("Thread2 acquired lock2");
                }
            }
        });

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

这个例子中,两个线程尝试按不同的顺序获取两把锁,可能导致死锁。

4.资源访问的顺序性

在某些情况下,对共享资源的访问需要按照特定的顺序进行。锁可以确保这种顺序性,使得资源的访问和修改按预期的顺序执行。

解决资源访问的无序性情况:

我们使用synchronized关键字确保increment()和getValue()方法不会被多个线程同时执行。

public class SynchronizedOrder {
    private static int value = 0;

    public synchronized static void increment() {
        value++;
    }

    public synchronized static int getValue() {
        return value;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> increment());
        Thread thread2 = new Thread(() -> System.out.println(getValue()));

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

5.避免过度访问:

在高并发环境中,过多的线程同时访问某个资源可能会导致资源过载或系统崩溃。锁可以限制对资源的并发访问,从而避免这种情况。

解决过度访问情况:
import java.util.concurrent.Semaphore;

public class Server {
    // 允许的最大并发请求数
    private static final int MAX_CONCURRENT_REQUESTS = 5;

    // 使用信号量来限制并发请求数量
    private final Semaphore semaphore = new Semaphore(MAX_CONCURRENT_REQUESTS);

    public void handleRequest() {
        try {
            semaphore.acquire();
            System.out.println("Handling request by " + Thread.currentThread().getName());
            Thread.sleep(2000); // 模拟处理请求所需的时间
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release();
        }
    }

    public static void main(String[] args) {
        Server server = new Server();
        
        // 启动10个线程尝试并发访问服务器
        for (int i = 0; i < 10; i++) {
            new Thread(() -> server.handleRequest()).start();
        }
    }
}


我们使用了Semaphore来模拟并发请求的限制。虽然我们尝试使用10个线程并发地访问服务器,但实际上只有5个线程能够在同一时间内访问服务器。这样,我们就避免了服务器过载的问题。

这个例子展示了如何使用锁(在这里是信号量)来避免资源的过度访问,确保系统的健壮性和性能。

锁的设计思想

锁的核心设计思想是提供一种机制,能确保在任意时刻,只有一个线程(或进程)能访问特定的资源或执行特定的代码段。这种机制在多线程环境中是必要的,因为多个线程同时访问共享资源可能导致数据不一致、竞态条件或其他不可预测的行为。

通俗地说,锁就像是一个房间里的独立卫生间。假设一个家庭有四个人,但卫生间只有一个。早上,每个人都需要使用卫生间来准备上班或上学。

为了避免混乱,家庭成员之间有一个默契:如果有人正在使用卫生间,其他人就等待,直到卫生间空闲。这里的卫生间就像是一个锁,家庭成员就像是线程,每个家庭成员等待使用卫生间的行为就像是线程尝试获取锁的行为。

总之,锁的设计思想是为了确保多个线程或进程不会同时访问或修改同一资源,从而保证数据的完整性和程序的正确性。

锁的分类

1)按照锁的性质、行为和策略来分类

乐观锁(Optimistic Lock)

描述:乐观锁通常不是通过真正的“锁”实现的,而是基于数据版本(如版本号或时间戳)来实现。当线程要提交数据时,它会检查在此期间数据是否已经被其他线程修改。如果其他线程修改了数据,则当前线程的修改会失败。

通俗例子:假设你在图书馆借阅一本书,并做了一些笔记。当你准备还书时,如果发现这本书的某些页面被撕掉或更改,那么你的笔记可能就不再适用,并需要重新评估。

代码实现:
通常在数据库中实现,但这里我提供一个简化的版本。

import java.util.concurrent.atomic.AtomicLong;

public class OptimisticLockExample {
    private AtomicLong version = new AtomicLong(0);

    public void updateData() {
        long currentVersion = version.get();
        // ... 执行一些操作 ...

        // 检查版本号是否改变
        if (!version.compareAndSet(currentVersion, currentVersion + 1)) {
            // 重试或者报错
            System.out.println("Data was modified by another thread!");
        }
    }
}
悲观锁(Pessimistic Lock)

描述:悲观锁假设多个线程在访问数据时都会出现冲突,因此在数据被一个线程访问时,其他线程会被阻止访问,直到该锁被释放。

通俗例子:考虑一个人使用ATM取款。ATM在处理过程中会锁定那个账户,确保在处理过程中没有其他交易。

代码实现:
使用Synchronized作为示例。

public class PessimisticLockExample {
    public synchronized void updateData() {
        // ... 执行操作 ...
        System.out.println("Data updated with pessimistic lock");
    }
}

独享锁(Exclusive Lock)

描述:当一个线程获得独享锁时,其他线程无法获取该锁,直到持有锁的线程释放它。

通俗例子:一个公共厕所只能由一个人使用,其他人必须等待直到里面的人使用完并出来。

代码实现:
使用ReentrantLock为例。

import java.util.concurrent.locks.ReentrantLock;

public class ExclusiveLockExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void updateData() {
        lock.lock();
        try {
            // ... 执行操作 ...
            System.out.println("Data updated with exclusive lock");
        } finally {
            lock.unlock();
        }
    }
}
共享锁(Shared Lock)

描述:允许多个线程同时获取锁进行读操作,但只允许一个线程进行写操作。

通俗例子:图书馆的阅览室。多个人可以同时进入并读书,但如果有人想在墙上写东西或贴告示,则需要等待其他所有人离开。

代码实现:使用ReentrantReadWriteLock的readLock作为示例。

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class SharedLockExample {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public void readData() {
        lock.readLock().lock();
        try {
            // ... 执行读操作 ...
            System.out.println("Data read with shared lock");
        } finally {
            lock.readLock().unlock();
        }
    }
}


公平锁(Fair Lock)

描述:公平锁确保等待最长时间的线程首先获得锁。它基于一个先到先得的原则。

通俗例子:一个有序的队列如银行窗口。不管队伍里有多少人,新来的人都必须排在队伍的最后,等待他前面的人都办完业务。

代码实现:使用公平的ReentrantLock为例。

import java.util.concurrent.locks.ReentrantLock;

public class FairLockExample {
    private final ReentrantLock lock = new ReentrantLock(true); // 公平锁

    public void updateData() {
        lock.lock();
        try {
            // ... 执行操作 ...
            System.out.println("Data updated with fair lock");
        } finally {
            lock.unlock();
        }
    }
}
非公平锁(Non-fair Lock)

描述:允许“插队”。线程获取锁的顺序可能与他们请求锁的顺序不同。

通俗例子:一个公交车站。虽然大多数人都排队等车,但偶尔有人可能会直接走到队伍前面并乘车,而不是排在队伍的后面。

代码实现:使用默认的ReentrantLock(非公平)为例。

import java.util.concurrent.locks.ReentrantLock;

public class NonFairLockExample {
    private final ReentrantLock lock = new ReentrantLock(); // 非公平锁

    public void updateData() {
        lock.lock();
        try {
            // ... 执行操作 ...
            System.out.println("Data updated with non-fair lock");
        } finally {
            lock.unlock();
        }
    }
}

2)按照锁的粒度来分类

偏向锁 (Biased Locking)

描述: 偏向锁是一种针对单线程执行的代码进行优化的锁。如果一个锁是偏向的,那么它会认为总是只有一个线程来访问它,因此它会尝试避免使用真正的锁操作来减少不必要的性能开销。但如果另一个线程尝试获取这个锁,偏向锁就会被撤销。

适用场景: 当锁大部分时间都仅被一个线程访问,但偶尔会有其他线程尝试获取锁的情况下。

代码实现:偏向锁通常是JVM层面的优化,但为了简化,我们可以用一个伪代码来模拟这种行为:

public class BiasedLock {
    private Object lock = new Object();
    private Thread owningThread = null;

    public void lock() {
        if (owningThread == Thread.currentThread()) {
            // Already owns the lock, no need to synchronize
            return;
        }
        synchronized (lock) {
            owningThread = Thread.currentThread();
        }
    }

    public void unlock() {
        owningThread = null;
    }
}

这段代码提供了一个偏向锁的简化实现。当某个线程已经拥有该锁时,它可以快速再次获取这个锁而无需进行真正的同步操作。owningThread变量用于存储当前持有锁的线程。

轻量级锁 (Lightweight Locking)

描述: 轻量级锁是介于无锁状态和重量级锁状态之间的一种锁。当一个线程尝试获取一个已经被另一个线程获取的轻量级锁时,它会自旋,而不是被阻塞,这样它就可以在无需进入内核态的情况下检查锁状态。

适用场景: 当锁的竞争不激烈,但可能偶尔有多个线程尝试同时获取锁的情况下。

代码实现:这也是JVM层面的优化。简化后的示例:

public class LightweightLock {
    private volatile Thread owningThread = null;

    public void lock() {
        while (true) {
            if (owningThread == null) {
                synchronized (this) {
                    if (owningThread == null) {
                        owningThread = Thread.currentThread();
                        return;
                    }
                }
            }
        }
    }

    public void unlock() {
        owningThread = null;
    }
}

轻量级锁使用了自旋来尝试获取锁。如果锁是空闲的(owningThread == null),线程尝试获取它。否则,它会持续自旋直到锁可用。

重量级锁 (Heavyweight Locking)

描述: 当轻量级锁不足以满足需求时,锁会膨胀为重量级锁。此时,尝试获取锁的线程会被阻塞,而不是自旋。

适用场景: 当有大量线程竞争锁时,为了避免过多的CPU资源被浪费在自旋上,使用重量级锁更加合适。

代码实现:Java内置的synchronized关键字或ReentrantLock都可以看作是重量级锁。

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

    public void doWork() {
        synchronized (lock) {
            // Do some work here
        }
    }
}

分段锁 (Segmented Locking)

描述: 分段锁是一种将数据结构(例如哈希表)分成不同的段,并为每个段分配一个锁的策略。这样,多个线程可以同时写入数据结构的不同部分,而不会互相阻塞。

适用场景: 主要用于数据结构,如ConcurrentHashMap,其中需要在高并发条件下保持高性能。

代码实现:ConcurrentHashMap使用分段锁来管理其存储。为简化,我们可以考虑一个分段数组:

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

public class SegmentedLock<T> {
    private Object[] segments;
    private Lock[] locks;

    public SegmentedLock(int segmentsSize) {
        segments = new Object[segmentsSize];
        locks = new ReentrantLock[segmentsSize];
        for (int i = 0; i < segmentsSize; i++) {
            locks[i] = new ReentrantLock();
        }
    }

    public void put(int index, T value) {
        locks[index % segments.length].lock();
        try {
            segments[index % segments.length] = value;
        } finally {
            locks[index % segments.length].unlock();
        }
    }

    public T get(int index) {
        locks[index % segments.length].lock();
        try {
            return (T) segments[index % segments.length];
        } finally {
            locks[index % segments.length].unlock();
        }
    }
}

此代码提供了一个分段锁的简单实现。这种锁的优点是可以减少锁竞争,从而提高并发性能。

总结

本文主要说了锁的核心概念、它所能解决的问题、其背后的解决策略以及设计思想,最后也展开了锁的各种分类。如有遗漏或误解,敬请各位读者朋友 关注微信公众号:大圣数据星球 与我交流与讨论。

下一篇文章我会继续说,解决并发读写用到的另一个知识点 MVCC ,让大家对并发编程有一个全局的认识。

大家不用着急,MVCC 讲完,我就开始从一个大数据框架的源码去给大家解剖,来说明熟练掌握这些并发知识是非常有必要的,希望大家再等等。



这篇关于并发编程之锁:守护数据的魔法的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程