引言
在现实开发中,我们或多或少都经历过,因为并发的问题,导致的数据不一致的问题,究其原因,是因为在某些场景下,某一个变量值被多个用户访问并修改,那么如何保证该变量在并发的场景过程中正确的修改,保证每个用户使用的正确性呢?今天我们来聊聊线程同步的概念。
一般来说,程序并行化是为了获得更高的执行效率,但有一个非常重要的前提就是是,高效率不能以牺牲正确性为代价。如果程序并行化后,连基本的执行结果的正确性都无法保证,那么并行程序本身也就没有任何意义了。因此,线程的安全问题就是并行程序的根本和不可动摇的根基。为例解决这些问题,我们先聊聊临界区的概念。
我们可以简单地把程序代码分成两部分:不会导致竞争条件的程序片段和会导致竞争条件的程序片段。会导致竞争条件的程序片段就叫做临界区。避免竞争条件只需要阻止多个进程同时读写共享的数据就可以了,也就是保证同时只有一个进程处于临界区内。
💌🧚♀️💗🌨🥡🍥 我们可以用_《现代操作系统》_书中的配图来理解。
Java为我们提供了同步机制,帮助程序员实现临界区。当一个线程想要访问一个临界区时,它使用其中的一个同步机制来找出是否有任何其他线程执行临界区。如果没有,这个线程就进入临界区。否则,这个线程通过同步机制暂停直到另一个线程执行完临界区。当多个线程正在等待一个线程完成执行的一个临界区时,JVM选择其中一个线程执行,其余的线程会等待直到轮到它们。
临界区(Critical Section)是指在多线程环境下,多个线程共享的代码段,这些代码段需要互斥地访问共享资源。为了确保临界区的正确性,我们需要遵循以下规则:
- 🌈 原子性(Atomicity):临界区内的操作必须是原子的,即要么全部执行成功,要么全部不执行。在临界区内,如果一个线程正在执行操作,其他线程必须等待,直到该线程完成操作。
- 🌈 互斥性(Mutual Exclusion):在临界区内,只能有一个线程在执行操作。如果一个线程正在执行临界区内的操作,其他线程必须等待,直到该线程完成操作。
- 🌈 有界等待(Bounded Waiting):线程在等待进入临界区时,不能无限期地等待。有界等待意味着线程在等待进入临界区时,最多等待一定次数。这可以防止线程饥饿现象。
- 🌈 资源分配图(Resource Allocation Graph):资源分配图是一种用于描述系统资源分配情况的图形表示。在临界区中,资源分配图必须满足以下条件:
- 🔬 每个资源节点表示一个共享资源。
- 🔬 每个进程节点表示一个线程。
- 🔬 如果一个线程正在使用某个资源,则在资源节点和进程节点之间存在一条有向边。
- 🔬 如果一个线程正在等待使用某个资源,则在资源节点和进程节点之间存在一条无向边。
根据资源分配图,我们可以判断系统是否处于安全状态。如果系统处于安全状态,那么所有线程都能按照某种顺序执行临界区内的操作,从而避免死锁。
为了实现临界区的正确性,我们可以使用各种同步机制,如互斥锁(Mutex)、信号量(Semaphore)、监视器(Monitor)等。这些同步机制可以帮助我们确保临界区的原子性、互斥性和有界等待。在实际开发中,我们需要根据具体需求选择合适的同步机制,以平衡性能和正确性。
Java提供了多种同步机制来实现临界区,例如synchronized
关键字和ReentrantLock
类。synchronized
关键字可以用于修饰方法或代码块,当一个线程进入被synchronized
修饰的方法或代码块时,其他线程必须等待当前线程执行完毕后才能进入。ReentrantLock
类是一个可重入的互斥锁,它提供了比synchronized
更灵活的锁定和解锁操作。
📄🖍️o(≧o≦)o🧸 Java语言为解决同步问题提供了两种主要的机制:
synchronized
关键字和Lock
接口及其实现。以下是这两种机制的简要介绍:
Synchronized
synchronized
关键字是Java语言内置的同步机制,用于确保同一时刻只有一个线程可以访问共享资源。synchronized
关键字可以用于修饰方法或代码块。当一个线程进入被synchronized
修饰的方法或代码块时,其他线程必须等待,直到该线程退出方法或代码块。synchronized
关键字的实现基于Java虚拟机(JVM)的监视器锁(Monitor Lock)机制。
示例 ☎️
我们使用synchronized关键字修饰increment()和getCount()方法,以确保同一时刻只有一个线程可以访问这些方法。
public class SynchronizedExample {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Lock
Java提供了java.util.concurrent.locks
包,其中包含了Lock
接口及其实现,如ReentrantLock
。Lock
接口提供了比synchronized
关键字更灵活的同步机制。Lock
接口的实现类(如ReentrantLock
)提供了lock()
、unlock()
等方法,用于显式地获取和释放锁。此外,Lock
接口还支持可中断的锁获取、公平锁等特性。
示例 ☎️
我们使用ReentrantLock实现类来实现同步。我们在increment()和getCount()方法中显式地获取和释放锁,以确保同一时刻只有一个线程可以访问这些方法。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private int count = 0;
private final 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();
}
}
}
Java语言为解决同步问题提供了两种主要的机制:synchronized
关键字和Lock
接口及其实现。synchronized
关键字提供了简单易用的同步机制,而Lock
接口及其实现提供了更灵活、更强大的同步机制。在实际开发中,我们需要根据具体需求选择合适的同步机制,以平衡性能和正确性。
Synchronized 作用
在Java中,synchronized
关键字用于实现线程同步,它可以确保在同一时间只有一个线程可以访问被synchronized
修饰的代码块或方法,从而保证共享资源在并发访问时的正确性。以下是synchronized
的主要作用:
- 保证原子性:
synchronized
可以确保被修饰的代码块或方法在同一时间只被一个线程访问,从而避免了多个线程同时访问导致的数据不一致问题。这样可以确保操作的原子性,使得每个线程都能得到正确的结果。 - 实现互斥:
synchronized
可以实现互斥,即在同一时间只允许一个线程访问被修饰的代码块或方法。这样可以防止多个线程同时访问共享资源,从而避免资源竞争和数据不一致的问题。 - 保证有序性:
synchronized
可以确保被修饰的代码块或方法按照顺序执行。当一个线程正在访问被synchronized
修饰的代码块或方法时,其他线程必须等待,直到当前线程执行完毕。这样可以确保代码的执行顺序,避免出现数据不一致的问题。 - 防止死锁:虽然
synchronized
可以实现线程同步,但如果使用不当,可能会导致死锁。为了避免死锁,我们需要合理地使用synchronized
,避免在一个线程中嵌套使用多个synchronized
代码块或方法,以及避免在synchronized
代码块或方法中调用可能会阻塞的操作。
☎️ 示例:
increment()和getCount()方法都使用了synchronized关键字,确保了在同一时间只有一个线程可以访问这两个方法,从而保证了count变量的正确性。
public class Counter {
private int count;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
synchronized
关键字在Java中起到了关键作用,它可以帮助我们实现线程同步,确保共享资源在并发访问时的正确性。在实际开发中,我们需要根据具体需求选择合适的同步机制,以平衡性能和正确性。
用法 && 示例 ☎️
synchronized
关键字可以用于修饰方法或代码块,以实现线程同步。以下是synchronized
关键字的几种用法及示例:
修饰方法: 🍑
当synchronized
修饰一个方法时,它会在方法执行期间锁定当前对象。在同一时间,只有一个线程可以访问被synchronized
修饰的方法,其他线程必须等待。
public class Counter {
private int count;
public synchronized void increment() {
count++;
}
}
模拟业务场景:多个用户同时尝试增加计数器的值。
public class CounterDemo {
public static void main(String[] args) {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter: " + counter.getCount());
}
}
修饰代码块: 🍑
当synchronized
修饰一个代码块时,它会在代码块执行期间锁定指定的对象。在同一时间,只有一个线程可以访问被synchronized
修饰的代码块,其他线程必须等待。
public class Counter {
private int count;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
}
模拟业务场景:多个用户同时尝试增加计数器的值。
public class CounterDemo {
public static void main(String[] args) {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter: " + counter.getCount());
}
}
修饰静态方法: 🍑
当synchronized
修饰一个静态方法时,它会在方法执行期间锁定当前类的Class对象。在同一时间,只有一个线程可以访问被synchronized
修饰的静态方法,其他线程必须等待。
public class Counter {
private static int count;
public static synchronized void increment() {
count++;
}
}
模拟业务场景:多个用户同时尝试增加计数器的值。
public class CounterDemo {
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
Counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter: " + Counter.getCount());
}
}
修饰静态代码块: 🍑
当synchronized
修饰一个静态代码块时,它会在代码块执行期间锁定当前类的Class对象。在同一时间,只有一个线程可以访问被synchronized
修饰的静态代码块,其他线程必须等待。
public class Counter {
private static int count;
public static void increment() {
synchronized (Counter.class) {
count++;
}
}
}
模拟业务场景:多个用户同时尝试增加计数器的值。
public class CounterDemo {
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
Counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter: " + Counter.getCount());
}
}
修饰类: 🍑
虽然synchronized
不能直接修饰类,但我们可以通过修饰类的静态方法或静态代码块来实现类级别的同步。
public class Counter {
private static int count;
public static synchronized void increment() {
count++;
}
}
模拟业务场景:多个用户同时尝试增加计数器的值。
public class CounterDemo {
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
Counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Counter: " + Counter.getCount());
}
}
Volatile
volatile
关键字和synchronized
关键字都可以用于确保多线程环境下的变量可见性和有序性,但它们之间有一些区别。以下是volatile
关键字和synchronized
关键字的主要区别:
- 性能:
volatile
关键字相比于synchronized
关键字具有更低的性能开销。volatile
关键字仅确保变量的可见性,而synchronized
关键字则确保变量的可见性和有序性。因此,在只需要确保变量可见性的场景下,使用volatile
关键字比使用synchronized
关键字更高效。 - 原子性:
volatile
关键字不能保证原子性。原子性是指一个操作要么全部执行成功,要么全部不执行。在多线程环境下,如果一个线程正在执行volatile
变量的操作,其他线程可能会看到该操作的部分结果。而synchronized
关键字可以确保原子性,即要么全部执行成功,要么全部不执行。 - 锁的粒度:
volatile
关键字适用于简单的变量访问和修改场景,而synchronized
关键字则适用于更复杂的同步场景,如方法调用、代码块等。volatile
关键字仅影响单个变量,而synchronized
关键字可以影响整个对象或代码块。 - 可见性:
volatile
关键字确保变量的可见性,即当一个线程修改了volatile
变量的值,其他线程能够立即看到修改后的值。而synchronized
关键字也确保变量的可见性,但它通过锁机制实现,可能会导致线程阻塞。 - 有序性:
volatile
关键字不能保证有序性。有序性是指程序中的操作按照一定的顺序执行。在多线程环境下,如果一个线程正在执行volatile
变量的操作,其他线程可能会看到该操作的乱序执行。而synchronized
关键字可以确保有序性,即程序中的操作按照一定的顺序执行。
在Java语言规范第三版(Java Language Specification, Third Edition)中,对**volatile
**关键字的定义如下:
A field may be declared
volatile
__, in which case the Java Memory Model ensures that all threads see a consistent value for the variable.
翻译成中文:
一个字段可以被声明为
volatile
,在这种情况下,Java内存模型确保所有线程看到该变量的一致值。
**这里的关键词是“consistent value”,即一致值。****volatile
**关键字确保了以下几点:
- 可见性:当一个线程修改了
volatile
变量的值,其他线程能够立即看到修改后的值。 - 有序性:
volatile
关键字禁止指令重排序。这意味着在volatile
变量的读写操作之前和之后的代码,不会被重排序到这些操作之后或之前。 - 原子性:对于基本数据类型(如
int
、long
等)的读写操作,volatile
关键字确保了原子性。但是,对于复合操作(如++
、--
等),volatile
关键字不能保证原子性。
需要注意的是,虽然volatile
关键字确保了变量的可见性和有序性,但它并不能替代synchronized
关键字。在需要更复杂的同步场景(如方法调用、代码块等)时,仍然需要使用synchronized
关键字。在实际开发中,我们需要根据具体需求选择合适的关键字,以平衡性能和正确性。在只需要确保变量可见性的场景下,使用volatile
关键字比使用synchronized
关键字更高效。
可见性
Java内存模型(Java Memory Model,简称JMM)规定了主内存(Main Memory)和每个线程的工作内存(Working Memory)之间的交互规则。在多线程环境下,每个线程都有自己的工作内存,而主内存中存储着共享变量。线程对变量的操作都在工作内存中进行,然后将操作后的值刷新到主内存,以便其他线程能够看到这些变化。
当一个线程修改了一个变量的值,其他线程可能无法立即看到这个变化,因为它们可能在自己的工作内存中缓存了该变量的旧值。这就是可见性问题。为了解决这个问题,Java提供了volatile
关键字。
当一个变量被声明为volatile
时,它告诉JMM:
不要对这个变量进行缓存优化,确保每个线程都能看到最新的值。
不要对这个变量的读写操作进行重排序。
这样,即使编译器和处理器想要进行优化,也会因为volatile
的存在而遵守这些规则。因此,使用volatile
关键字可以确保变量的可见性。
以下是一个简单的示例,说明了volatile
关键字如何解决可见性问题:
我们使用volatile关键字修饰counter变量。当两个线程同时调用increment()方法时,它们会直接从主内存中读取和写入counter的值,确保每个线程都能看到最新的值。因此,输出的Counter值应该是2000,表示两个线程都成功地增加了counter的值。
public class VolatileExample {
private volatile int counter = 0;
public void increment() {
counter++;
}
public int getCounter() {
return counter;
}
public static void main(String[] args) throws InterruptedException {
VolatileExample example = new VolatileExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Counter: " + example.getCounter());
}
}
让我们通过一个例子来深入了解volatile
关键字的作用。
不使用 Volatile
🍿
首先,我们来看一个不使用volatile
关键字的简单例子。这个例子中有一个Counter
类,它有一个count
变量,用于计数。我们有两个线程,每个线程都会增加count
的值1000次。
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class VolatileExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + counter.getCount());
}
}
理论上,我们期望count
的最终值是2000,因为每个线程都增加了1000次。但在实际运行中,由于编译器和处理器可能对代码进行重排序,以及线程之间的可见性问题,count
的值可能小于2000。
使用 Volatile
🍿
现在,我们将count
变量声明为volatile
,并再次运行上面的例子。
public class Counter {
private volatile int count = 0;
// 其他代码不变...
}
通过将count
声明为volatile
,我们告诉Java虚拟机(JVM):
- 不要对这个变量进行缓存优化,确保每个线程都能看到最新的值。
- 不要对这个变量的读写操作进行重排序。
这样,即使编译器和处理器想要进行优化,也会因为volatile
的存在而遵守这些规则。因此,使用volatile
后,count
的最终值更有可能接近2000。
小结 🍿
volatile
关键字提供了一种轻量级的同步机制,主要用于确保多线程环境下变量的可见性。然而,它并不能保证复合操作的原子性。在这个例子中,虽然volatile
提高了count
的可见性,但increment()
方法仍然不是线程安全的,因为count++
是一个复合操作(读、改、写)。对于需要原子性保证的场景,我们仍然需要使用synchronized
或其他同步工具。
写在最后
在多线程编程中,正确地使用同步机制是至关重要的,因为它们可以帮助我们避免数据不一致、竞态条件等问题,从而提高程序的可靠性和性能。我们应该熟练掌握Java的线程同步机制,并在实际编程中根据具体需求选择合适的同步策略。通过不断地学习和实践,我们可以编写出更加高效、可靠的多线程程序。