线程同步机制:synchronized 关键字与锁对象
大家好,欢迎来到我的线程同步世界!今天咱们要聊聊Java并发编程中的一位老朋友,也是一位核心人物——synchronized 关键字。它就像一位沉默的守护者,默默地保护着我们的共享数据,防止多线程环境下出现混乱,让我们一起揭开它的神秘面纱。
1. 为什么需要线程同步?
想象一下这样的场景:你和你的小伙伴同时操作银行账户。你准备取钱,他准备存钱。如果没有人协调,你们可能同时读到账户余额,然后分别计算新的余额,最终导致账户余额出错。这就是并发问题,也就是多个线程同时访问和修改共享数据时可能出现的问题。
更具体一点,想想以下的代码:
public class Counter {
private int count = 0;
public void increment() {
count++; // 这不是原子操作!
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join(); // 等待t1线程执行完毕
t2.join(); // 等待t2线程执行完毕
System.out.println("最终计数: " + counter.getCount()); // 结果可能不是20000
}
}
这段代码看似简单,两个线程各自增加计数器 10000 次,期望最终结果是 20000。然而,实际运行结果往往小于 20000。这是因为 count++ 并非原子操作,它实际上包含了三个步骤:
- 读取
count的值。 - 将
count的值加1。 - 将新的值写回
count。
在多线程环境下,这三个步骤可能被其他线程打断,导致数据不一致。例如:
- 线程 1 读取
count的值为5。 - 线程 2 读取
count的值为5。 - 线程 1 将
count的值加1,得到6,并写回count。 - 线程 2 将
count的值加1,得到6,并写回count。
结果是,count 只增加了 1,而不是 2。
为了解决这类问题,我们需要线程同步机制,保证对共享数据的操作是原子性的,防止数据竞争。而 synchronized 关键字就是Java提供的最基本的同步机制之一。
2. synchronized 关键字:一位忠实的守护者
synchronized 关键字可以用来修饰方法或者代码块,它的作用是:保证在同一时刻,只有一个线程可以执行被 synchronized 修饰的代码。它就像一把锁,当一个线程获取到锁后,其他线程必须等待,直到该线程释放锁才能继续执行。
2.1 synchronized 修饰方法
当 synchronized 修饰一个方法时,它锁定的是整个方法。这意味着,当一个线程调用该方法时,它会自动获取与该方法所属对象关联的锁。其他线程想要调用该方法,必须等待该线程释放锁。
public class SynchronizedCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedCounter counter = new SynchronizedCounter();
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终计数: " + counter.getCount()); // 结果保证是20000
}
}
在这个例子中,increment() 和 getCount() 方法都被 synchronized 修饰。这意味着,同一时刻,只有一个线程可以执行这两个方法中的任意一个。这有效地避免了数据竞争,保证了 count 的正确性。
2.2 synchronized 修饰代码块
synchronized 也可以用来修饰代码块,语法如下:
synchronized (lockObject) {
// 需要同步的代码
}
其中 lockObject 是一个对象,它可以是任何对象,只要所有需要同步的线程都使用同一个 lockObject 就可以了。当一个线程执行到 synchronized 代码块时,它会尝试获取 lockObject 关联的锁。如果锁被其他线程占用,该线程就会阻塞,直到锁被释放。
public class SynchronizedBlockCounter {
private int count = 0;
private Object lock = new Object(); // 锁对象
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBlockCounter counter = new SynchronizedBlockCounter();
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终计数: " + counter.getCount()); // 结果保证是20000
}
}
在这个例子中,我们使用 lock 对象作为锁。只有获取到 lock 对象的锁,线程才能执行 synchronized 代码块中的代码。
3. 锁对象:this 锁 和 类锁
synchronized 关键字背后隐藏着锁对象的概念。Java中的锁对象可以分为两种:
this锁(对象锁): 当synchronized修饰一个非静态方法时,它锁定的是this对象,也就是调用该方法的对象。- 类锁: 当
synchronized修饰一个静态方法或者使用synchronized(类名.class)时,它锁定的是该类的Class对象。
3.1 this 锁 (对象锁)
当我们使用 synchronized 修饰一个非静态方法时,锁对象就是当前对象 this。这意味着,不同的对象拥有不同的锁,互不影响。
public class ThisLockExample {
private int count = 0;
public synchronized void increment() {
count++;
System.out.println(Thread.currentThread().getName() + ": count = " + count);
}
public static void main(String[] args) {
ThisLockExample obj1 = new ThisLockExample();
ThisLockExample obj2 = new ThisLockExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
obj1.increment();
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
obj2.increment();
}
}, "Thread-2");
t1.start();
t2.start();
}
}
在这个例子中,obj1 和 obj2 是两个不同的对象,它们拥有不同的 this 锁。因此,Thread-1 和 Thread-2 可以同时访问 increment() 方法,互不阻塞。你会发现输出结果是交替的。
3.2 类锁
当我们使用 synchronized 修饰一个静态方法时,或者使用 synchronized(类名.class) 时,锁对象是该类的 Class 对象。由于一个类只有一个 Class 对象,因此所有该类的对象共享同一个锁。
public class ClassLockExample {
private static int count = 0;
public static synchronized void increment() {
count++;
System.out.println(Thread.currentThread().getName() + ": count = " + count);
}
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
ClassLockExample.increment();
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
ClassLockExample.increment();
}
}, "Thread-2");
t1.start();
t2.start();
}
}
在这个例子中,increment() 方法被 synchronized 修饰,它锁定的是 ClassLockExample.class 对象。因此,Thread-1 和 Thread-2 必须串行执行 increment() 方法,你会发现输出结果是线程1执行完,线程2再执行。
或者使用 synchronized(ClassLockExample.class):
public class ClassLockExample2 {
private static int count = 0;
public static void increment() {
synchronized (ClassLockExample2.class) {
count++;
System.out.println(Thread.currentThread().getName() + ": count = " + count);
}
}
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
ClassLockExample2.increment();
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
ClassLockExample2.increment();
}
}, "Thread-2");
t1.start();
t2.start();
}
}
效果是一样的。
4. synchronized 的原理:Monitor 对象
synchronized 关键字的底层实现依赖于 Monitor 对象。每个Java对象都关联着一个 Monitor 对象,当 synchronized 关键字修饰的代码被执行时,线程会尝试获取该对象关联的 Monitor 锁。
Monitor 对象内部维护着一个锁计数器,当计数器为 0 时,表示锁未被占用;当计数器大于 0 时,表示锁已被占用。当一个线程成功获取锁时,计数器加 1;当线程释放锁时,计数器减 1。当计数器变为 0 时,锁被释放,其他等待的线程可以尝试获取锁。
简单来说,Monitor 对象的作用就是管理锁的获取和释放,保证线程的同步。
5. synchronized 的特性
synchronized 关键字具有以下特性:
- 原子性: 保证被
synchronized修饰的代码块作为一个原子操作执行,不可中断。 - 可见性: 保证一个线程对共享变量的修改对其他线程是可见的。这是因为,当一个线程释放锁时,它会将工作内存中的共享变量刷新到主内存中;当一个线程获取锁时,它会从主内存中读取最新的共享变量。
- 有序性:
synchronized可以防止指令重排序。在synchronized代码块内部,指令的执行顺序与代码的编写顺序一致。
6. synchronized 的使用注意事项
- 过度同步: 不要过度使用
synchronized,过度同步会导致性能下降。只对需要同步的代码块进行同步。 - 死锁: 避免死锁。死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都无法继续执行的情况。
- 锁的粒度: 尽量减小锁的粒度,提高并发性能。例如,可以使用
ConcurrentHashMap代替HashMap。 - 避免长时间持有锁: 尽量缩短持有锁的时间,避免阻塞其他线程。
7. synchronized 的适用场景
synchronized 关键字适用于以下场景:
- 保护共享数据: 当多个线程需要同时访问和修改共享数据时,可以使用
synchronized关键字保护共享数据,防止数据竞争。 - 实现线程安全: 可以使用
synchronized关键字将非线程安全的类转换为线程安全的类。 - 控制资源访问: 可以使用
synchronized关键字控制对共享资源的访问,例如数据库连接、文件等。
8. synchronized 的替代方案
除了 synchronized 关键字,Java还提供了其他线程同步机制,例如:
- Lock 接口:
Lock接口提供了比synchronized关键字更强大的功能,例如可重入锁、公平锁、条件变量等。 - 原子类:
java.util.concurrent.atomic包提供了原子类,例如AtomicInteger、AtomicLong等,可以实现原子操作,避免使用锁。 - 并发集合:
java.util.concurrent包提供了并发集合,例如ConcurrentHashMap、CopyOnWriteArrayList等,可以安全地在多线程环境下使用。
9. 总结
synchronized 关键字是Java并发编程中最基本的同步机制之一。它可以保证原子性、可见性和有序性,防止数据竞争,实现线程安全。但是,synchronized 关键字也存在一些缺点,例如性能较低、容易导致死锁等。在实际开发中,需要根据具体情况选择合适的同步机制。
记住,synchronized 是一位忠实的守护者,它可以保护我们的共享数据,防止多线程环境下出现混乱。但是,我们也要合理使用它,避免过度同步,才能充分发挥并发编程的优势。
希望这篇文章能够帮助你更好地理解 synchronized 关键字,并在实际开发中灵活运用。祝你编程愉快!