1.什么是synchronized
synchronized 是 Java 并发模块 非常重要的关键字,它是 Java 内建的一种同步机制,代表了某种内在锁定的概念,当一个线程对某个 共享资源 加锁后,其他想要获取共享资源的线程必须进行等待,synchronized 也具有互斥和排他的语义。主要应用于多线程环境下保证线程的安全性。
2.synchronized的特性
2.1 原子性
所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。
被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。
2.2 可见性
可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。
synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。
而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。
2.3 有序性
有序性值程序执行的顺序按照代码先后执行。
synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
2.4 可重入性
ynchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。
3.synchronized的用法
synchronized
的使用其实比较简单,可以用它来修饰实例方法和静态方法,也可以用来修饰代码块。我们需要注意的是synchronized
是一个对象锁,也就是它锁的是一个对象。我们无论使用哪一种方法,synchronized
都需要有一个锁对象
- 修饰实例方法
- 修饰静态方法
- 修饰代码块
3.1.修饰实例方法
synchronized修饰实例方法, 在方法上加上synchronized关键字即可。
public class SynchronizedTest1 {
public synchronized void test() {
System.out.println("synchronized 修饰 方法");
}
}
此时,synchronized加锁的对象就是这个方法所在实例的本身,作用于当前实例加锁,进入同步代码前要获得当前实例的锁 。
3.2.修饰静态方法
synchronized修饰静态方法的使用与实例方法并无差别,在静态方法上加上synchronized关键字即可
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public synchronized static void method() {
for (int i = 0; i < 5; i ++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void run() {
method();
}
}
public class Demo00{
public static void main(String args[]){
SyncThread syncThread1 = new SyncThread();
SyncThread syncThread2 = new SyncThread();
Thread t1 = new Thread(syncThread1, "SyncThread1");
Thread t2 = new Thread(syncThread2, "SyncThread2");
t1.start();
t2.start();
}
}
由于静态方法不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。给静态方法加synchronized
锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前静态方法所在类的Class对象的锁。
有一点我们需要知道:如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
3.3.修饰代码块
synchronized修饰代码块需要传入一个对象。
public class SynchronizedTest2 {
public void test() {
synchronized (this) {
System.out.println("synchronized 修饰 代码块");
}
}
}
此时synchronized加锁对象即为传入的这个对象实例,指定加锁对象,进入同步代码库前要获得给定对象的锁 需要注意的是这里的this :
- synchronized(object) ,表示进入同步代码库前要获得 给定对象的锁
- synchronized(类.class) ,表示进入同步代码前要获得 给定 Class 的锁
- 最好不要使用 synchronized(String a) ,因为在 JVM 中,字符串常量池具有缓存功能,如果我们多次加锁,会加锁在同一个对象上
4.synchronized的锁机制
4.1 锁机制
锁机制指的是锁所具有的特性,由于每种锁的特性各异,合理运用不同的锁能够显著提升效率。
锁机制的出现,主要是为了解决多线程环境下数据访问的并发问题,确保数据的一致性和完整性,避免出现错误的结果。
4.2 锁机制的类型
4.2.1. 乐观锁与悲观锁
乐观锁假定数据通常不会出现并发冲突,仅在数据提交更新时,才对是否产生并发冲突进行检测。若发现冲突,会向用户返回错误信息,由用户决定后续操作。
例如,在一个在线文档编辑系统中,如果多个用户同时编辑同一个文档的不同部分,乐观锁可以先让用户进行编辑,在保存时检查是否有冲突。
悲观锁则总是预设最坏情况,每次获取数据时都认为他人会修改,因此每次拿数据时都会上锁,导致其他想获取该数据的线程阻塞,直至获取到锁。
比如在银行系统中,处理账户资金操作时,通常会使用悲观锁来确保数据的绝对安全。
需要注意的是:Synchronized 初始使用乐观锁策略,当发现锁竞争频繁时,会自动切换为悲观锁策略。
4.2.2. 重量级锁与轻量级锁
锁的核心特性“原子性”源于 CPU 等硬件设备提供的支持。
(1)CPU 提供了“原子操作指令”。
(2)操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁。
(3)JVM 基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。
同时要知道,synchronized 并不仅仅是对 mutex 进行封装,其内部还进行了诸多其他工作。
重量级锁的加锁机制重度依赖 OS 提供的 mutex,会产生大量的内核态与用户态切换,容易引发线程调度。这两种操作成本较高,一旦涉及切换,情况就会有很大变化。
比如在一个高并发的数据库系统中,如果频繁使用重量级锁,可能会导致系统性能严重下降。
轻量级锁的加锁机制尽可能避免使用 mutex,尽量在用户态代码中完成,实在无法解决时才使用 mutex,因此只有少量的内核态与用户态切换,不太容易引发线程调度。
而且,synchronized 开始是一个轻量级锁,若锁冲突严重,则会转变为重量级锁。
4.2.3. 自旋锁(Spin Lock)
以往,线程抢锁失败后会进入阻塞状态,放弃 CPU,需等待很久才能再次被调度。但实际上,在多数情况下,尽管当前抢锁失败,不久后锁就会被释放,没必要放弃 CPU。此时便可使用自旋锁来解决这一问题。
若获取锁失败,立即再次尝试获取锁,不断循环,直至获取到锁为止。第一次获取锁失败,第二次尝试会很快进行。一旦锁被其他线程释放,就能第一时间获取到锁。
自旋锁是一种典型的轻量级锁实现方式,其优点在于没有放弃 CPU,不涉及线程阻塞和调度,锁一旦释放就能立刻获取。缺点是若锁被其他线程持有时间较长,会持续消耗 CPU 资源(而线程挂起等待时不消耗 CPU)。
例如,在一个短时间内频繁获取锁的场景中,自旋锁可以提高性能。
另外,synchronized 中的轻量级锁策略大概率是通过自旋锁的方式实现的。
4.2.4. 公平锁与非公平锁
公平锁遵循“先来后到”原则。例如 B 比 C 先来,当 A 释放锁后,B 能先于 C 获取到锁。
非公平锁则不遵循这一原则,B 和 C 都有获取到锁的可能。
需要注意的是,操作系统内部的线程调度可视为随机的。若不做额外限制,锁就是非公平锁。若要实现公平锁,需依赖额外的数据结构来记录线程的先后顺序。
公平锁和非公平锁并无绝对的好坏之分,关键取决于适用场景。
比如在一个对响应时间要求较高的系统中,可能更倾向于使用非公平锁。
同时,synchronized 是非公平锁。
4.2.5. 可重入锁和不可重入锁
可重入锁指的是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如在一个有锁操作的递归函数中,若递归过程中该锁不会阻塞自身,那么这个锁就是可重入锁(因此也叫递归锁)。
在 Java 中,只要是以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。但 Linux 系统提供的 mutex 是不可重入锁。
此外,synchronized 是可重入锁。
例如,在一个递归计算的函数中,如果使用的锁是不可重入的,就会导致死锁。
4.2.6. 读写锁
在多线程环境中,数据的读取方之间不会产生线程安全问题,但数据的写入方相互之间以及和读取方之间需要进行互斥。若在两种场景下都使用同一把锁,会造成极大的性能损耗,读写锁因此应运而生。
读写锁(readers-writer lock),从英文名可看出,执行加锁操作时需额外表明读写意图,多个读取者之间不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问,主要存在读数据和写数据两种操作:两个线程都只是读数据时,不存在线程安全问题,可直接并发读取;两个线程都要写数据,存在线程安全问题;一个线程读,另一个线程写,也存在线程安全问题。
读写锁将读操作和写操作区别对待。Java 标准库提供了 ReentrantReadWriteLock 类来实现读写锁。ReentrantReadWriteLock.ReadLock 类表示读锁,提供了 lock/unlock 方法进行加锁解锁;ReentrantReadWriteLock.WriteLock 类表示写锁,同样提供了 lock/unlock 方法进行加锁解锁。
其中,读加锁和读加锁之间不互斥,写加锁和写加锁之间互斥,读加锁和写加锁之间互斥。
读写锁特别适用于“频繁读,不频繁写”的场景,此类场景十分常见。并且,synchronized 不是读写锁。
例如,在一个高并发的缓存系统中,如果读操作远远多于写操作,使用读写锁可以显著提高性能。
5.结语
synchronized
作为 Java 中重要的同步机制,通过其丰富的特性和多样的用法,以及不同类型的锁机制,为开发者在多线程编程中提供了有效的工具,帮助解决并发问题,保障程序的正确性和性能。但在实际应用中,需要根据具体的业务场景和性能需求,合理选择和运用,以实现高效、稳定的多线程程序。希望大家在今后的编程实践中,能够熟练掌握并灵活运用synchronized
,编写出更加优秀的多线程应用程序。