在Java中,线程安全是一个非常重要的概念,特别是在多线程编程环境中。线程安全意味着在并发执行的情况下,多个线程同时访问同一个对象或数据时,能够保持数据的一致性和完整性,不会出现数据错乱或不一致的状态。
线程安全问题的根源
线程安全问题通常发生在多个线程同时访问并修改共享数据时。由于线程的执行顺序和速度是不确定的(即所谓的“竞态条件”),这可能导致数据被意外地修改或破坏。
以下是一个经典的线程不安全的代码示例,这段代码在Java语言中创建了一个简单的计数器类,该类提供了一个增加计数的方法。在多线程环境中,这个方法不是线程安全的。
public class Counter {
private int count = 0;
public void increment() {
count++; // 非原子操作
}
public int getCount() {
return count;
}
}
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
// 创建两个线程,它们都会增加计数器的值
Thread t1 = new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
}
});
// Lambda表达式写法
// 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();
// 等待线程执行完毕
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印最终的计数
System.out.println("Final count is: " + counter.getCount());
}
}
这段代码在多线程环境下可能会出现以下问题:
-
竞态条件:
increment()
方法中的count++
操作看似简单,但实际上它包含了三个操作:读取count
的值,将count
的值加一,然后将新值写回count
。- 当两个线程几乎同时执行
increment()
方法时,它们可能会读取到相同的count
值,然后都将其增加,导致最终的结果比预期的少。
-
内存可见性:
- 在没有适当同步的情况下,线程对共享变量的修改可能对其他线程不可见。尽管这个问题在这个简单的例子中不太可能出现,但在更复杂的场景中,内存可见性问题可能导致严重的问题。
以下是可能发生的情况:
- 假设
count
的当前值是 10。 - 线程 A 读取
count
的值(10)到它的寄存器中。 - 线程 B 也读取
count
的值(10)到它的寄存器中。 - 线程 A 将它的寄存器中的值加一(得到 11),然后将结果写回内存。
- 线程 B 也将它的寄存器中的值加一(得到 11),然后将结果写回内存。
- 结果是
count
的最终值是 11,而不是预期的 12。
线程同步
线程同步指的是当有一个线程在对内存进行操作时,其他线程都不可以对该内存地址进行操作,直到该线程完成操作,其他线程才能对该内存地址进行操作。
这种思想的核心在于控制多个线程之间的执行顺序和它们对共享资源的访问方式,以避免数据不一致、竞态条件、死锁等问题。
其目的是为了保证多线程程序在执行过程中的数据一致性和线程安全性。
加锁是一种重要的线程同步机制
加锁:
每次只允许一个线程加锁,加锁后才能进入访问,访问完毕后自动解锁,然后其他线程才能在加锁进来。
同步代码块
在increment
方法中使用同步代码块来确保在同一时刻只有一个线程可以执行count++
操作。
public class Counter {
private int count = 0;
public void increment() {
// 使用this作为锁对象,因为我们要保护的是Counter类的实例变量
synchronized (this) {
count++;
}
}
public int getCount() {
// 如果只读取count,并且不修改它,通常不需要同步
// 但如果需要在极度精确的场景下保证读取的一致性(尽管在这个例子中不太需要),也可以考虑同步
return count;
}
}
同步代码块的作用
同步代码块的主要作用是将访问共享资源的核心代码段进行加锁处理,以此保证线程安全。在多线程环境下,多个线程可能会同时访问同一个共享资源,如果没有适当的同步机制,就可能导致数据不一致或线程安全问题。通过同步代码块,可以确保在同一时间内只有一个线程能够执行该代码段,从而保护共享资源不被并发访问所破坏。
同步代码块的原理
同步代码块的原理基于Java中的synchronized
关键字。当线程进入同步代码块时,会尝试获取指定的同步锁(也称为监视器锁)。如果锁已被其他线程持有,则该线程将阻塞,直到锁被释放。一旦线程成功获取锁,它将进入同步代码块执行其中的代码。执行完毕后,线程会自动释放锁,此时其他等待的线程可以尝试获取锁并进入同步代码块执行。通过这种方式,synchronized
关键字确保了每次只有一个线程能够执行同步代码块中的代码,从而实现了线程间的互斥访问。
同步方法
将整个increment
方法标记为同步的,这样也可以确保同一时刻只有一个线程可以执行这个方法。。
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
// 同样的,读取操作通常不需要同步
return count;
}
}
对比和解释
- 锁的范围:
- 同步方法:锁定整个方法,即方法内的所有代码都会被锁定。
- 同步代码块:只锁定代码块内的代码,方法内的其他代码不会被锁定。
- 灵活性:
- 同步方法:较为简单,适用于整个方法都需要同步的情况。
- 同步代码块:更加灵活,可以精确地控制需要同步的代码范围,并且可以使用不同的锁对象。
- 性能:
- 同步方法:由于锁定整个方法,可能会导致不必要的等待,降低性能。
- 同步代码块:由于只锁定需要同步的代码部分,可以减少等待时间,提高性能。
- 使用场景:
- 同步方法:适用于操作共享资源的代码占据整个方法的情况。
- 同步代码块:适用于操作共享资源的代码只是方法中的一部分,或者需要使用多个锁对象的情况。
同步锁的注意事项
- 同步锁必须是同一把(同一个对象):对于当前同时执行的线程来说,它们必须竞争同一把锁才能进入同步代码块。如果不同的线程使用不同的锁对象来同步访问共享资源,那么这些线程之间将不会实现互斥访问,从而导致数据不一致或线程安全问题。
- 避免死锁:在使用同步锁时,需要特别注意避免死锁的发生。死锁是指两个或多个线程相互等待对方释放锁而无法继续执行的情况。为了避免死锁,需要确保线程在获取锁时遵循一定的顺序,并且在持有锁期间尽量缩短执行时间。
- 减少锁的粒度:为了提高程序的并发性能,应该尽量减少锁的粒度。即尽量缩小同步代码块的范围,只将需要同步访问的共享资源包含在同步代码块中。这样可以减少线程之间的等待时间,提高程序的执行效率。
锁对象随便选择一个唯一的对象好不好呢?
锁对象随便选择一个唯一的对象,通常是不推荐的。
原因是这样的做法可能会带来不必要的线程竞争和性能开销。如果随意选择一个对象作为锁,那么任何需要同步的代码都可能尝试锁定这个对象,从而导致不同线程间的无谓竞争。这不仅会降低程序的并发性能,还可能引入死锁等线程安全问题。
更好的做法是,根据实际的共享资源来选择合适的锁对象。如果是实例级别的共享资源,通常应该使用当前实例(this
)作为锁对象;如果是类级别的共享资源,则应该使用类对象(如类名.class
)作为锁对象。这样做可以确保只有真正需要访问共享资源的线程才会去竞争锁,从而减少无谓的线程竞争和性能开销。
两大核心规范:
-
实例方法锁对象选择:对于实例方法,推荐使用当前实例(
this
)作为锁对象。这是因为实例方法通常操作的是实例级别的共享资源,使用this
作为锁可以确保在并发环境下访问这些资源时的线程安全。通过锁定当前实例,可以防止多个线程同时修改同一实例的共享状态,从而避免数据不一致的问题。 -
静态方法锁对象选择:对于静态方法,推荐使用类对象(如
类名.class
)作为锁对象。静态方法访问的是类级别的共享资源,而非特定实例的资源。因此,使用类对象作为锁对象可以确保在多个线程同时调用静态方法时,对类级别资源的访问是线程安全的。这样做可以防止不同实例间的线程干扰,保护类级别的共享数据不被并发修改导致的问题。
代码演示
实例方法锁对象选择
当我们在实例方法中使用锁时,通常会选择当前实例(this
)作为锁对象。这是因为实例方法通常操作的是该实例的共享资源。以下是一个简单的例子:
public class Counter {
private int count = 0;
public synchronized void increment() {
// 这里使用synchronized关键字,相当于以this为锁对象
count++;
}
public synchronized int getCount() {
// 同样,这里也是以this为锁对象
return count;
}
}
在上面的例子中,increment()
和 getCount()
方法都被声明为 synchronized
,这意味着它们在执行时会锁定当前实例(this
)。因此,如果有多个线程尝试同时调用同一个 Counter
实例的这两个方法,它们将会串行执行,从而确保 count
变量的线程安全。
静态方法锁对象选择
对于静态方法,我们推荐使用类对象(如 类名.class
)作为锁对象。这是因为静态方法访问的是类级别的共享资源。以下是一个例子:
public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
// 这里使用synchronized关键字,相当于以StaticCounter.class为锁对象
count++;
}
public static synchronized int getCount() {
// 同样,这里也是以StaticCounter.class为锁对象
return count;
}
}
在这个例子中,increment()
和 getCount()
方法都被声明为 static synchronized
,这意味着它们在执行时会锁定 StaticCounter.class
类对象。因此,即使有多个线程尝试同时调用这两个静态方法,它们也会串行执行,从而确保 count
静态变量的线程安全。
Lock锁
概述
Lock
接口提供了比synchronized
关键字更灵活的锁定机制。它允许开发者在不同的作用域内获取和释放锁,并且支持尝试获取锁、定时获取锁以及中断正在尝试获取锁的线程等操作。
Lock锁是一个接口,不能直接实例化,但可以通过其实现类如ReentrantLock来创建锁对象。这种机制允许开发者更精细地控制锁的行为,包括尝试获取锁、定时获取锁、以及中断获取锁等操作。
ReentrantLock构造器
public ReentrantLock()
是ReentrantLock类的一个构造器,用于创建Lock锁的实现类对象。通过这个构造器,可以轻松地获得一个ReentrantLock实例,进而实现加锁和解锁的功能。
Lock的常用方法
- lock():此方法用于获取锁。如果锁已被其他线程占用,则当前线程将阻塞,直到锁被释放。
- unlock():此方法用于释放锁。调用此方法后,其他线程可以获取该锁。
以下是一个使用ReentrantLock
的示例代码:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private final Lock lock = new ReentrantLock(); // 创建一个ReentrantLock实例
private int count = 0; // 共享资源
public void increment() {
lock.lock(); // 获取锁
try {
count++; // 对共享资源进行操作
} finally {
lock.unlock(); // 释放锁
}
}
public int getCount() {
lock.lock(); // 获取锁
try {
return count; // 返回共享资源的值
} finally {
lock.unlock(); // 释放锁
}
}
public static void main(String[] args) {
LockExample example = new LockExample();
// 创建并启动两个线程,对count进行累加操作
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();
// 等待两个线程执行完毕
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出最终的count值
System.out.println("Final count is: " + example.getCount());
}
}
在这个示例中,我们创建了一个LockExample
类,其中包含一个ReentrantLock
实例和一个共享资源count
。increment()
方法用于增加count
的值,而getCount()
方法用于获取count
的当前值。这两个方法都使用了lock()
和unlock()
方法来确保对共享资源的访问是线程安全的。
在main()
方法中,我们创建了两个线程t1
和t2
,它们分别调用increment()
方法1000次。由于increment()
方法是线程安全的,因此最终的count
值将是2000,而不是一个不确定的值。最后,我们输出count
的最终值来验证结果的正确性。