一文速通JUC中的各种锁

举报
yd_249383650 发表于 2023/03/30 19:14:43 2023/03/30
【摘要】 ​乐观锁和悲观锁乐观锁悲观锁(synchronized关键字和Lock的实现类都是悲观锁)    什么是悲观锁?认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改    适合写操作多的场景,先加锁可以保证写操作时数据正确(写操作包括增删改)、显式的锁定之后再操作同步资源    synchronized关键字和Lock的实现类都是悲观锁悲...

乐观锁和悲观锁

乐观锁

悲观锁(synchronized关键字和Lock的实现类都是悲观锁)

    什么是悲观锁?认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改
    适合写操作多的场景,先加锁可以保证写操作时数据正确(写操作包括增删改)、显式的锁定之后再操作同步资源
    synchronized关键字和Lock的实现类都是悲观锁

悲观锁是一种锁机制,它假设在同时访问同一数据时会发生冲突,因此采取防护措施来避免产生冲突。悲观锁通常在对数据进行修改操作时使用,它会在读取数据时对数据进行加锁,以确保修改时不会有其他线程同时修改数据。悲观锁常常会造成性能问题,因为它会在访问数据时频繁地进行加锁和解锁操作。常见的悲观锁实现机制包括数据库的行锁和表锁、Java中的synchronized关键字和ReentrantLock类等。

 乐观锁

乐观锁是一种并发控制机制,基于假设多数情况下数据访问之间没有冲突,所以没有加锁,只在需要写入数据时先检查数据版本是否变更,如果版本号一致则更新数据,否则认为操作可能冲突,停止操作,并让用户重试。通常是在数据表中添加一个版本号字段,在比较版本号的基础上实现并发控制。这种机制适用于读操作多、写操作少的情况。常见的实现方式有基于版本号、时间戳等。

举个例子,假设有一个账户表,其中有字段account_balance表示账户余额,当一个用户想要向另一个用户转账时,需要先检查转出账户的余额是否足够,如果足够则进行转账操作。如果是乐观锁实现,则在转账时不需要对数据表加锁,而是从转出账户和转入账户的余额开始判断,检查两个账户的余额是否符合要求,如果符合要求,则分别更新两个账户的余额字段。如果在更新账户余额字段之前,有其他的并发操作更新了账户余额字段,则这次操作失败,需要返回错误信息,并让用户再次重试。

 java中怎么实现乐观锁

Java中可以通过使用版本号或时间戳来实现乐观锁。

1. 使用版本号

在数据表中增加一个版本号字段,每次更新数据时都会更新版本号。当多个线程同时请求数据时,会先读取数据的版本号,然后更新该字段。如果版本号没有发生变化,则说明在读取和更新数据的过程中没有其他线程修改过数据,可以正常更新数据。如果版本号变化了,则需要回滚操作或重新尝试更新数据。

示例代码:

```java
//获取当前版本号
long version = getVersion(id);

//尝试更新数据
updateData(id, newData);

//获取更新后的版本号
long newVersion = getVersion(id);

//比较版本号是否一致
if (version != newVersion) {
    throw new OptimisticLockException("数据已经被修改,更新失败");
}
```

2. 使用时间戳

在数据表中增加一个时间戳字段,每次更新数据时都会更新时间戳。当多个线程同时请求数据时,会先读取数据的时间戳,然后更新该字段。如果时间戳没有发生变化,则说明在读取和更新数据的过程中没有其他线程修改过数据,可以正常更新数据。如果时间戳变化了,则需要回滚操作或重新尝试更新数据。

示例代码: 


```java
//获取当前时间戳
long timestamp = System.currentTimeMillis();

//尝试更新数据
updateData(id, newData, timestamp);

//获取更新后的时间戳
long newTimestamp = getTimestamp(id);

//比较时间戳是否一致
if (timestamp != newTimestamp) {
    throw new OptimisticLockException("数据已经被修改,更新失败");
}
```
需要注意的是,乐观锁只能保证在更新数据和检查版本号/时间戳之间没有其他线程修改数据,不能保证在整个业务流程中数据的一致性。因此,在实际使用中还需要结合其他技术,如事务、分布式锁等,来保证数据的完整性和一致性。

 公平锁和非公平锁

公平锁和非公平锁是指在多线程环境中对于互斥资源的获取方式的不同。

公平锁指的是线程在申请互斥资源时按照申请的先后顺序进行获取,先来先得的原则。如果一个线程请求公平锁时没有获取到,它就会在队列中等待,直到轮到它申请时才能获取到。公平锁保证了每个线程获取资源的机会是相同的。

非公平锁则不遵循先来先得的原则,而是由操作系统随机选择一个线程来执行,如果这个线程无法获取资源,它就会继续尝试获取,直到成功。非公平锁优先考虑资源的效率,而非等待时间。

举个例子,假设有一个电影院,公平锁的方式是按照排队的先后顺序给观众分配座位,而非公平锁则是让值得信任的观众提前进场选择座位。对于公平锁,每个观众都有同等的机会获得最好的座位,但是需要等下一个座位空闲后才能进入;而对于非公平锁,虽然可以更高效地分配座位,但是可能会让一些观众等待很长时间才能进入。

为什么会有公平锁、非公平锁的设计?为什么默认非公平?

公平锁和非公平锁的设计是为了解决多个线程共享资源时的竞争问题。公平锁会按照请求的顺序来分配锁,也就是先到先得的原则;而非公平锁则不考虑请求的顺序,直接分配锁,可能会出现某些线程一直无法获取到锁的情况。

默认情况下,大多数锁都是非公平锁,这是因为非公平锁的效率比公平锁要高,尤其是当锁竞争不频繁时,非公平锁的性能优势就更加明显。同时,公平锁需要维护等待队列,需要更多的系统资源来管理锁的请求,也会增加锁的响应时间。因此,在大多数情况下,非公平锁是更优的选择。但在某些特定的场景下,如对锁请求的顺序有严格要求的场景,公平锁则是更合适的选择。

 可重入锁

可重入锁(Reentrant Lock)是一种支持同一个线程对锁的重复加锁的锁,也称为递归锁。

例如,假设一个方法中需要先获取锁,然后进行一系列操作,期间还需要调用其他方法,如果这些方法中也需要获取同一个锁进行操作,这时就需要使用可重入锁,否则会出现死锁或其他线程无法获取该锁的情况。

举例1:银行取钱
在银行取钱时,会进入一个方法中进行取款操作,其中需要先获取锁,然后进行相关操作,假设还需要查询账户余额的方法,那么在查询余额的方法中也需要获取同一个锁进行操作,此时就需要使用可重入锁来避免死锁或其他线程无法获取该锁的情况。

``java
public class Bank {
    private ReentrantLock lock = new ReentrantLock();
    private double balance;

    public void withdraw(double amount) {
        lock.lock();
        try {
            // 取钱操作
        } finally {
            lock.unlock();
        }
    }

    public double getBalance() {
        lock.lock();
        try {
            // 查询余额操作
        } finally {
            lock.unlock();
        }
        return balance;
    }
}
```


举例2:文件操作
在文件操作中,可能需要对文件进行读写操作,如果在进行写操作时,又需要进行读操作,此时就需要使用可重入锁来避免死锁或其他线程无法获取该锁的情况。

```java
public class FileUtil {
    private ReentrantLock lock = new ReentrantLock();

    public void write(String file, String data) {
        lock.lock();
        try {
            // 写入操作
            read(file); // 调用读操作
        } finally {
            lock.unlock();
        }
    }

    public void read(String file) {
        lock.lock();
        try {
            // 读取操作
        } finally {
            lock.unlock();
        }
    }
}
```

死锁及排查

死锁是指在并发程序中,两个或多个线程被永久地阻塞,它们在等待系统提供的资源,而这些资源却被占用了。换句话说,死锁通常发生在并发程序中,当两个或多个线程间彼此互相等待对方释放需要的资源时,就可能形成死锁。

例如,假设有两个线程A和B分别要占用资源X和Y来完成任务,但是A在占用X后等待Y的释放,而B在占用Y后等待X的释放。此时,A和B都在等待对方释放资源,它们将永远不能完成任务,就形成了死锁。


```java
public class DeadlockExample {

    public static void main(String[] args) {
        Object lock1 = new Object();
        Object lock2 = new Object();

        // 线程1获取锁1,尝试获取锁2
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock1) {
                    System.out.println("Thread 1 acquired lock 1");
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock2) {
                        System.out.println("Thread 1 acquired lock 2");
                    }
                }
            }
        });

        // 线程2获取锁2,尝试获取锁1
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock2) {
                    System.out.println("Thread 2 acquired lock 2");
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock1) {
                        System.out.println("Thread 2 acquired lock 1");
                    }
                }
            }
        });

        t1.start();
        t2.start();
    }
}
```

在此示例中,线程1获取锁1并休眠100毫秒,然后尝试获取锁2,而线程2则获取锁2并休眠100毫秒,然后尝试获取锁1。当这两个线程同时运行时,会发生死锁:线程1持有锁1而等待锁2,而线程2持有锁2而等待锁1,这样它们彼此都无法继续执行。

为了排查死锁问题,可以使用如下方法:

1. 分析程序中使用的锁:查看程序中使用的锁,以及锁的获取和释放的位置。
2. 分析程序资源管理的方式:了解程序中资源的获取和释放方式,以及是否存在资源占用的情况。
3. 使用工具诊断:使用工具分析程序的运行情况,如jstack、jvisualvm、jconsole等,来定位死锁的原因。
4. 通过日志排查:在程序中增加日志输出,记录程序中关键的锁和资源的获取和释放操作,以及出现死锁时的堆栈信息,从而定位死锁。
5. 调整程序设计:使用更加合理的并发编程方式,如避免使用共享资源、减少互斥等方式,来避免死锁问题的发生

自旋锁

Java中的自旋锁是一种非阻塞锁,当多个线程同时竞争一个锁时,其他线程会一直循环重试获取锁,而不是进入阻塞状态等待锁释放。这种方式可以减少线程上下文切换的开销,提高线程执行效率。

举例:在Java中,可以使用关键字synchronized来实现自旋锁。例如:

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

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    acquireLock();
                    count++;
                    releaseLock();
                }
            }).start();
        }
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }

    private static void acquireLock() {
        while (true) {
            synchronized (lock) {
                if (count == 0) {
                    return;
                }
            }
        }
    }

    private static void releaseLock() {
        synchronized (lock) {
            count--;
        }
    }
}
```


在这个例子中,使用synchronized关键字来实现一个自旋锁,当count等于0时,线程可以获取锁进行累加操作,否则一直循环尝试获取锁,直到锁被释放。当所有线程执行完成后,输出累加结果。



【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。