在说线程安全的问题上,有一个概念很重要,我们要先聊聊这个概念,它就是临界资源
。
临界资源是指在多线程环境下被多个线程同时访问和操作的共享资源。它可以是对象、变量、文件或其他系统资源。临界资源具有两个关键特性:
- 共享性:多个线程可以同时访问临界资源,因为它被设计为供多个线程使用。这意味着多个线程可以读取、写入或修改临界资源。
- 可变性:临界资源可以在其生命周期内被修改。这意味着多个线程可以对临界资源进行操作,从而改变其状态或内容。
由于多个线程可以并发地访问和修改临界资源,可能会导致一些问题,如数据竞争、不确定的结果或数据不一致性。因此,在多线程编程中,保护临界资源的一致性和正确性非常重要。
为了避免多个线程同时访问临界资源导致的问题,我们需要采取适当的同步机制,如synchronized
关键字或其他并发控制机制,来确保在任意时刻只有一个线程可以访问临界资源。这样可以避免竞态条件和数据冲突,保证线程之间的协调和数据的正确性。
不过有一点需要注意,当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。接下来我们就围绕synchronized关键字来讲解它的使用方式。
synchronized关键字的使用
synchronized可以用于修饰方法或代码块,以实现对临界资源的同步访问。
同步方法
在方法声明中使用synchronized关键字可以确保整个方法的执行在同一时间只能被一个线程访问。当一个线程进入synchronized方法时,它会获取方法所属对象的锁,其他线程将被阻塞,直到锁被释放。
public class SynchronizedExample {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
public class SynchronizedTest {
public static void main(String[] args) {
SynchronizedExample example = new SynchronizedExample();
// 创建并启动多个线程
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
thread1.start();
thread2.start();
// 等待线程执行完毕
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出最终的count值
System.out.println("Count: " + example.getCount());
}
}
输出结果:
Count: 2000
假如我把synchronized关键字去掉,这个结果会是什么?
为了避免偶然性,我测试了10组数据,结果为:
Count: 2000
Count: 1985
Count: 2000
Count: 1528
Count: 2000
Count: 1799
Count: 2000
Count: 2000
Count: 1718
Count: 2000
可以看到,并不会每次都按照预期的方式执行,有时会出现多线程竞争临界资源而导致数据不一致的情况发生。
上面这段代码主要在SynchronizedExample类中是定义了两个同步方法:increment()和getCount()。然后创建两个线程,分别对SynchronizedExample对象的increment()方法进行了1000次自增操作。最后输出count的值,预期结果为2000,因为每个线程都进行了1000次自增操作。
实际的结果可以看出,使用synchronized关键字的同步方法是按照预期的输出了结果,而没有采用同步方法的实验中,会出现与预期不符的结果。
既然同步方法这么好用,是不是就没副作用了呢?
还真不是,虽然使用synchronized同步方法可以确保线程安全,但也可能存在一些问题,比如:
- 阻塞:当一个线程获取到锁并进入synchronized方法时,其他线程需要等待该线程释放锁才能执行相同的方法。这可能导致线程阻塞和等待时间增加,对系统性能产生负面影响。
- 粒度过大:如果一个类中的多个方法都被声明为synchronized,即使这些方法之间并不涉及共享资源的访问,也会造成线程之间的不必要阻塞。在某些情况下,可以考虑使用更细粒度的同步方式,只对需要同步的代码块进行同步。
- 潜在死锁问题:同一个线程可以多次获得同步方法的锁,这称为可重入性。但要注意,线程必须释放相同次数的锁,否则会导致死锁。
- 单一锁对象:使用synchronized同步方法时,锁是基于对象的。如果多个方法使用不同的对象作为锁,那么它们将无法实现同步,因为每个方法都获得了自己的锁。确保多个同步方法使用的是同一个锁对象,才能保证同步的正确性。
- 不适用于跨越多个方法的复杂操作:synchronized同步方法适用于简单的同步需求,例如对共享变量的原子操作。但对于涉及多个方法的复杂操作,使用synchronized同步方法可能会导致代码复杂性增加,可读性下降。
- 性能问题:synchronized同步方法对于高并发场景可能存在性能瓶颈。因为一次只能有一个线程执行同步方法,其他线程需要等待,导致并发性能降低。在一些情况下,可以考虑使用更高效的同步机制,如Lock接口的实现类。
在日常开发中,我们需要权衡使用synchronized同步方法的利弊,根据具体需求选择合适的同步方式,以确保线程安全同时最大程度地提高系统性能。
同步代码块
除了同步方法,synchronized还可以用于修饰代码块,以细粒度地控制对共享资源的访问。可以使用任意对象作为锁,只要所有线程共享相同的锁对象即可。
修改一下刚刚SynchronizedExample类中的实现,其他不变
public class SynchronizedExample {
// 定义共享锁的对象
private static final Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
除了在方法级别或代码块级别使用synchronized关键字之外,synchronized还有其他用法,
比如同步实例方法,在类的实例方法上使用synchronized关键字,确保在同一时间只有一个线程能够执行该方法。
public synchronized void someMethod() {
// 方法体
}
或者同步静态方法,在类的静态方法上使用synchronized关键字,确保在同一时间只有一个线程能够执行该静态方法。
public static synchronized void someStaticMethod() {
// 方法体
}
还有像同步代码块(类锁),使用synchronized关键字修饰静态代码块,使用类对象作为锁,确保在同一时间只有一个线程能够执行该静态代码块。
public static void someStaticMethod() {
synchronized (ClassName.class) {
// 静态代码块
}
}
到这里说了synchronized关键字的使用,以及对临界资源的理解,相信可以应付一些日常的开发了。