线程同步机制: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
关键字,并在实际开发中灵活运用。祝你编程愉快!