Synchronized 介绍
概念:synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
线程的执行:代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。
Synchronized 的三种使用方式
Java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础。synchronized 的三种使用方式如下:
普通同步方法(实例方法)
锁是当前实例对象 ,进入同步代码前要获得当前实例的锁。为了更加深刻的体会 synchronized 作用于实例方法的使用,我们先来设计一个场景,并根据要求,通过代码的实例进行实现。
场景设计:
- 创建两个线程,分别设置线程名称为 threadOne 和 threadTwo;
- 创建一个共享的 int 数据类型的 count,初始值为 0;
- 两个线程同时对该共享数据进行增 1 操作,每次操作 count 的值增加 1;
- 对于 count 数值加 1 的操作,请创建一个单独的 increase 方法进行实现;
- increase 方法中,先打印进入的线程名称,然后进行 1000 毫秒的 sleep,每次加 1 操作后,打印操作的线程名称和 count 的值;
- 运行程序,观察打印结果。
结果预期:因为 increase 方法有两个打印的语句,不会出现 threadOne 和 threadTwo 的交替打印,一个线程执行完 2 句打印之后,才能给另外一个线程执行。
public class DemoTest extends Thread { //共享资源 static int count = 0; /** * synchronized 修饰实例方法 */ public synchronized void increase() throws InterruptedException { sleep(1000); count++; System.out.println(Thread.currentThread().getName() + ": " + count); } @Override public void run() { try { increase(); } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) throws InterruptedException { DemoTest test = new DemoTest(); Thread t1 = new Thread(test); Thread t2 = new Thread(test); t1.setName("threadOne"); t2.setName("threadTwo"); t1. start(); t2. start(); }
结果验证
threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。 threadTwo: 1 threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。 threadOne: 2
从结果可以看出,threadTwo 进入该方法后,休眠了 1000 毫秒,此时线程 threadOne 依然没有办法进入,因为 threadTwo 已经获取了锁,threadOne 只能等待 threadTwo 执行完毕后才可进入执行,这就是 synchronized 修饰实例方法的使用。
Tips:仔细看 DemoTest test = new DemoTest () 这就话,我们创建了一个 DemoTest 的实例对象,对于修饰普通方法,synchronized 关键字的锁即为 test 这个实例对象。
静态同步方法
锁是当前类的 class 对象 ,进入同步代码前要获得当前类对象的锁
Tips:对于 synchronized 作用于静态方法,锁为当前的 class,要明白与修饰普通方法的区别,普通方法的锁为创建的实例对象。为了更好地理解,我们对第 5 点讲解的代码进行微调,然后观察打印结果。
代码修改:其他代码不变,只修改如下部分代码。
- 新增创建一个实例对象 testNew ;
- 将线程 2 设置为 testNew 。
public static void main(String[] args) throws InterruptedException { DemoTest test = new DemoTest(); DemoTest testNew = new DemoTest(); Thread t1 = new Thread(test); Thread t2 = new Thread(testNew); t1.setName("threadOne"); t2.setName("threadTwo"); t1. start(); t2. start(); }
结果验证:
threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。 threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。 threadTwo: 1 threadOne: 2
结果分析:我们发现 threadTwo 和 threadOne 同时进入了该方法,为什么会出现这种问题呢?
因为我们此次的修改是新增了 testNew 这个实例对象,也就是说,threadTwo 的锁是 testNew ,threadOne 的锁是 test。
两个线程持有两个不同的锁,不会产生互相 block。相信讲到这里,同学对实例对象锁的作用也了解了,那么我们再次将 increase 方法进行修改,将其修改成静态方法,然后输出结果。
代码修改:
public static synchronized void increase() throws InterruptedException { System.out.println(Thread.currentThread().getName() + "获取到锁,其他线程在我执行完毕之前,不可进入。" ); sleep(1000); count++; System.out.println(Thread.currentThread().getName() + ": " + count); }
结果验证:
threadOne获取到锁,其他线程在我执行完毕之前,不可进入。 threadOne: 1 threadTwo获取到锁,其他线程在我执行完毕之前,不可进入。 threadTwo: 2
结果分析:我们看到,结果又恢复了正常,为什么会这样?
关键的原因在于,synchronized 修饰静态方法,锁为当前 class,即 DemoTest.class。
public class DemoTest extends Thread {}
无论 threadOne 和 threadTwo 如何进行 new 实例对象的创建,也不会改变锁是 DemoTest.class 的这一事实。
同步方法块
锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
Tips:对于 synchronized 作用于同步代码,锁为任何我们创建的对象,只要是个对象即可,如 new Object () 可以作为锁,new String () 也可作为锁,当然如果传入 this,那么此时代表当前对象。
我们将代码恢复到普通同步方法的知识,然后在此基础上,再次对代码进行如下修改:
代码修改:
/** * synchronized 修饰实例方法 */ static final Object objectLock = new Object(); //创建一个对象锁 public static void increase() throws InterruptedException { System.out.println(Thread.currentThread().getName() + "获取到锁,其他线程在我执行完毕之前,不可进入。" ); synchronized (objectLock) { sleep(1000); count++; System.out.println(Thread.currentThread().getName() + ": " + count); } }
代码解析:我们创建了一个 objectLock 作为对象锁,除了第一句打印语句,让后三句代码加入了 synchronized 同步代码块,当 threadOne 进入时,threadTwo 不可进入后三句代码的执行。
结果验证:
threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。 threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。 threadOne: 1 threadTwo: 2
Synchronized的底层实现原理
1. Monitor锁
synchronized
的实现依赖于Java对象头中的Monitor锁。当一个线程进入同步代码块或方法时,它需要获得该对象的Monitor锁,其他线程则无法获取该锁,直到当前线程释放锁。Monitor锁的管理依赖于底层操作系统的互斥量(mutex)。
2. 对象头结构
Java对象头包含了用于存储对象自身数据和锁状态的信息。在HotSpot虚拟机中,对象头主要包含两部分:
- Mark Word:用于存储对象的运行时数据,如哈希码、GC信息、锁状态等。
- Class Metadata Address:指向对象的类元数据。
Mark Word在不同的锁状态下(无锁、偏向锁、轻量级锁和重量级锁)会存储不同的数据。
3. 锁的状态
synchronized锁有四种状态,随着锁竞争的加剧,锁状态会逐步升级,但升级是不可逆的:
- 无锁状态:此时对象头中的Mark Word存储对象的哈希码。
- 偏向锁:当一个线程第一次获得锁时,会在对象头中记录下该线程ID,如果以后该线程再次获得锁,不需要再进行CAS操作来加锁。
- 轻量级锁:当锁被多个线程竞争时,偏向锁会升级为轻量级锁,通过CAS操作进行加锁和解锁。
- 重量级锁:当锁竞争激烈,轻量级锁无法满足要求时,锁会膨胀为重量级锁,此时会通过操作系统的互斥量来实现线程同步。
4. 锁的升级和膨胀过程
- 无锁状态:线程第一次进入同步块,Mark Word中存储的是对象的哈希码。
- 偏向锁:如果只有一个线程多次进入同步块,Mark Word中记录线程ID,避免每次都进行CAS操作。
- 轻量级锁:如果有多个线程争用锁,会尝试使用CAS操作进行锁竞争。
- 重量级锁:如果锁竞争激烈,轻量级锁无法满足要求,会升级为重量级锁,通过操作系统的互斥量来实现同步。
5. 锁的释放
当线程退出同步块或方法时,会释放锁:
- 偏向锁:直接释放锁,不需要CAS操作。
- 轻量级锁:通过CAS操作释放锁。
- 重量级锁:通过操作系统的互斥量释放锁,并唤醒等待线程。
Synchronized的性能优化
尽管synchronized关键字在Java 1.6之后得到了极大的优化,但在高并发场景下,仍可能引入较高的性能开销。以下是一些优化技巧:
- 减少锁的粒度:尽可能缩小同步块的范围,减少持有锁的时间。
- 读写分离:使用ReentrantReadWriteLock来区分读操作和写操作,提升并发性能。
- 无锁数据结构:在可能的情况下,使用无锁的数据结构(如ConcurrentHashMap)来减少锁的竞争。
**示例:使用synchronized实现线程安全的计数器 **
public class SynchronizedCounter { private int count = 0; // 同步方法 public synchronized void increment() { count++; } // 同步代码块 public void decrement() { synchronized (this) { count--; } } public synchronized int getCount() { return count; } public static void main(String[] args) { SynchronizedCounter counter = new SynchronizedCounter(); // 创建多个线程进行计数操作 Thread[] threads = new Thread[10]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < 1000; j++) { counter.increment(); } }); } for (Thread thread : threads) { thread.start(); } for (Thread thread : threads) { try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("Final count: " + counter.getCount()); } }
总结
synchronized关键字通过Monitor锁和底层操作系统的互斥量实现线程同步,确保多线程环境下共享资源的安全访问。随着锁竞争的加剧,锁状态会从无锁逐步升级到偏向锁、轻量级锁和重量级锁。理解synchronized的底层实现原理有助于我们在高并发编程中做出更优化的设计决策。