在我们的并发编程旅程中,我们必将遇到各种挑战和困难。线程间的同步
、数据的一致性
以及并发中的竞态条件
等问题,都是我们必须要面对并解决的。而解决这些问题的关键,往往就是使用锁。锁是我们在并发世界中的守护者,它能帮助我们在并发世界混乱时,找到一丝秩序,保证代码的正确性和一致性。
然而,锁并不是万能的,错误的使用锁可能会引发死锁
、饥饿
等问题。因此,如何正确地使用锁,避免这些并发问题,就显得尤为重要。在这篇博客中,我们将一起探讨锁在并发编程中的角色,学习如何正确地使用锁,以及如何避免常见的并发问题。接下来,让我们共同探讨锁。
在并发编程中,锁是一种同步机制,用于在多个线程间实现对共享资源的独占访问。当一个线程需要访问一个被其他线程占用的资源时,这个线程就会被阻塞,直到锁被释放。通过这种方式,锁确保了同一时间只有一个线程可以修改共享资源。
锁的主要作用是为了保证数据一致性和防止数据竞争。在没有锁的情况下,如果两个线程同时修改同一份数据,可能会导致数据不一致的情况,这被称为数据竞争。通过使用锁,我们可以保证任何时刻只有一个线程修改数据,从而避免了数据竞争。
在Java中,我们可以使用synchronized关键字来创建内置锁,也可以使用java.util.concurrent.locks包中的Lock接口和ReentrantLock类来创建显式锁。
Java语言提供了内置的锁机制,这种锁也被称为监视器锁。我们可以通过synchronized关键字来创建和使用内置锁。synchronized可以修饰方法或者代码块。
Java并发库还提供了更强大的锁机制,即显式锁。显式锁是通过代码显式地获取和释放锁。相比于synchronized,显式锁提供了更多的灵活性,比如可以尝试获取锁,如果无法立即获取锁,线程可以决定等待还是放弃。
锁可以根据多个标准进行分类,如下所示:
独占锁 / 排他锁 独占锁是指该锁一次只能被一个线程所持有。在Java中,ReentrantLock和synchronized都是独占锁。它保证了每次只有一个线程执行同步代码,它的优点是避免了并发和线程安全问题,缺点是可能会引起线程阻塞。
共享锁 共享锁是指该锁可被多个线程所持有。对于Java中的ReentrantReadWriteLock,它的读锁是共享锁,写锁是独占锁。读锁的共享锁可以保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
可重入锁 可重入锁,也叫做递归锁,指的是一个线程已经拥有某个锁,可以无阻塞的再次请求这个锁。也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可以减少死锁的发生。
非重入锁 非重入锁,指的是锁不可以被一个已经拥有它的线程多次获取。在Java中,synchronized和ReentrantLock都不属于非重入锁。
公平锁 公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。Java中的ReentrantLock可以通过构造函数指定是否为公平锁,默认是非公平锁。
非公平锁 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发的情况下,有可能会造成优先级反转或者饥饿现象。
synchronized是Java内置的一种原始锁机制。我们可以通过在方法或者代码块上添加synchronized关键字来使用它。
synchronized方法
public class SynchronizedDemo {
public synchronized void method() {
// 业务逻辑代码
}
}
在这个例子中,我们在方法method
上添加了synchronized
关键字。这意味着当一个线程进入这个方法时,它将会获取到这个对象的锁,其他任何线程都无法进入这个方法,直到这个线程退出这个方法,释放这个对象的锁。
synchronized代码块
public class SynchronizedDemo {
private Object lock = new Object();
public void method() {
synchronized (lock) {
// 业务逻辑代码
}
}
}
在这个例子中,我们在代码块上添加了synchronized
关键字。这意味着当一个线程进入这个代码块时,它将会获取到lock
对象的锁,其他任何线程都无法进入这个代码块,直到这个线程退出这个代码块,释放lock
对象的锁。
ReentrantLock是java.util.concurrent包提供的一种锁机制。它提供了与synchronized相同的互斥性和内存可见性,但是添加了类似锁投票、定时锁等候和锁中断等更多功能。
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock();
try {
// 业务逻辑代码
} finally {
lock.unlock();
}
}
}
在这个例子中,我们创建了一个ReentrantLock对象lock
。当一个线程进入method
方法时,它将会调用lock.lock()
获取锁,其他任何线程都无法通过lock.lock()
,直到这个线程调用lock.unlock()
释放锁。
对于Java并发锁的工作原理,我们将以synchronized
和ReentrantLock
为例进行说明。
synchronized是依赖于JVM底层来实现的,其主要的执行原理可以分为三部分:
ReentrantLock的工作原理在很大程度上与synchronized相似,但是它更加灵活,提供了更多的功能。
重入:无论是synchronized还是ReentrantLock,它们都支持重入,即在持有锁的线程内,可以多次无阻塞地获取同一把锁。
在我们讨论了并发编程的基本概念和锁的基本工作原理后,让我们来深入探讨一下,锁是如何解决并发编程中的关键问题之一 - 原子性问题的。
在并发编程中,原子性是指一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。如果一个操作符合这个定义,我们就可以说它是原子操作。但是在实际编程中,很多操作并不能满足原子性,例如count++
就不是一个原子操作,因为它实际上包含了三个步骤:读取count
的值,对count
加一,把新的值写回count
。
为了解决这个问题,Java提供了锁机制,包括synchronized
关键字和Lock
接口。锁的基本工作原理是,当一个线程要执行一个锁住的代码块时,它必须先获得锁,如果锁已经被其他线程持有,那么它就会进入等待状态,直到其他线程释放锁。这就保证了在同一时刻,只有一个线程能够执行锁住的代码块,也就实现了原子性。
那么,让我们来看一个使用锁实现原子性的例子:
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
在这个例子中,我们使用synchronized
关键字来锁住increment
方法。这样,就能保证在同一时刻,只有一个线程可以执行increment
方法,实现了count++
的原子性。
对于更复杂的场景,我们也可以使用Lock
接口。例如,ReentrantLock
就是一个支持重入的锁,它可以在同一个线程内多次获取,这对于复杂的并发操作非常有用。
通过以上的解释,我们可以看出锁是如何通过保证同一时间只有一个线程访问特定代码块来解决原子性问题的。但请记住,虽然锁可以解决原子性问题,但并不能保证线程安全,因为它并不能解决可见性和有序性这两个问题。
在介绍了锁的基本概念和如何解决原子性问题之后,我们可以开始介绍更高级的并发控制机制,比如管程。以下是可能的内容。
我们刚刚讨论了如何使用锁解决原子性问题,现在我们来探讨一种更高级的并发控制机制 - 管程。
管程(Monitor)是一种同步机制,比锁提供了更高级的抽象。管程包含了一组预定义的程序和数据结构(比如共享变量和锁)的集合,这些程序只能被一个线程一次执行。这种一次性的特性,就保证了在一个时刻只有一个线程可以访问管程的资源,从而避免了并发冲突。
管程模型有两个关键部分,一个是互斥性(Mutual exclusion),这意味着任意时刻只允许一个线程执行管程中的一段代码。另一个是条件同步(Conditional synchronization),这意味着允许一个线程等待某个条件,直到这个条件满足时,线程才被唤醒继续执行。
以下是一个简单的管程的Java实现:
public class MonitorExample {
private int a = 0;
private boolean condition = false;
public synchronized void method1() {
while (!condition) {
try {
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 管程中的操作
a++;
}
public synchronized void method2() {
// 改变条件并唤醒等待的线程
condition = true;
notifyAll();
}
}
在上述代码中,MonitorExample
类实现了一个管程。这个管程包含一个共享变量a
和一个条件condition
。method1
方法是一个同步方法,它等待condition
为true
。当condition
为true
时,它会增加a
的值。method2
方法也是一个同步方法,它改变condition
的值并唤醒所有等待的线程。
我们可以看到,管程提供了一种有效的方式来处理并发程序中的复杂问题,使得编程变得更简单。然而,管程并不是银弹,我们仍然需要注意其他并发问题,例如死锁。在接下来的部分,我们将介绍死锁及其解决方案。
死锁是指两个或者多个线程在执行过程中,由于竞争资源而造成的一种相互等待的现象,如果没有外力干涉那它们都将无法推进下去。 在Java中,死锁经常出现在多线程中,特别是在多个synchronized块中。一个典型的死锁例子如下:
public class DeadlockDemo {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 1 over");
}
}
}).start();
new Thread(() -> {
synchronized (lock2) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println("Thread 2 over");
}
}
}).start();
}
}
在这个例子中,两个线程分别持有lock1和lock2,然后各自尝试获取对方持有的锁,由于双方都不放弃自己持有的锁,所以形成了死锁。
锁优化是指通过一些手段,尽可能地减少锁的使用,提高并发性能。以下是Java中常见的锁优化技术:
乐观锁和悲观锁不是具体的锁,而是指并发控制的两种策略。 乐观锁认为自己在使用数据时不会有其他线程修改数据,所以不会添加锁,只在更新数据时进行检查。如果发现数据已经被修改,那么操作会重新进行,直到成功为止。 悲观锁则相反,认为自己在使用数据时总会有其他线程来修改数据,因此在每次读写数据时都会加锁,这样可以确保数据的安全,但是付出的代价是并发性能。
假设我们有一个银行账户类,它有一个余额字段balance,我们需要在多线程环境下保护这个字段的安全性。于是我们使用了synchronized关键字:
public class Account {
private double balance;
public synchronized void deposit(double money) {
double newBalance = balance + money;
try {
Thread.sleep(10); // 模拟此业务需要一段处理时间
} catch (InterruptedException e) {
e.printStackTrace();
}
balance = newBalance;
}
public double getBalance() {
return balance;
}
}
在这个例子中,我们在deposit方法上添加了synchronized关键字,保证了balance字段在多线程环境下的安全性。
锁是并发编程中的一个重要概念,了解和掌握锁的使用和原理,能够帮助我们写出高效并且安全的并发代码。在编程时,要尽可能地减少锁的使用,避免死锁,选择适当的并发控制策略,这样才能保证程序的并发性能。 在掌握了基本的锁知识后,我们还需要了解一些锁的优化技术,如锁消除、锁粗化、轻量级锁和偏向锁等。这些优化技术能够在不降低程序安全性的前提下,提高程序的并发性能。 最后,希望这篇文章能够帮助你对并发编程中的锁有更深入的理解和应用。在日常的编程和面试中,锁都是一个重要的话题,希望你能够通过学习,熟练掌握并使用它。
阅读量:2017
点赞量:0
收藏量:0