一、synchronized介绍
synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。它能够保证在同一时刻,被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
synchronized的作用主要有三个:
原子性:确保线程互斥地访问同步代码; 可见性:保证共享变量的修改能够及时可见; 有序性:有效解决重排序问题。
"原子性"表示一个操作是不可分割的,要么成功执行,要么完全不执行。通常与线程同步相关,可以确保多个线程同时对共享变量进行读写时不会产生竞争条件和数据冲突。
"可见性"指当一个线程修改了某个共享变量的值后,另外一个线程能够立即看到这个修改的结果。如果缺乏可见性,就可能导致多个线程访问同一共享变量时出现不一致的情况。
"有序性"指程序在执行时的顺序必须与代码的顺序保持一致,不会出现重排或乱序执行的情况。如果没有有序性保证,就可能会导致线程执行顺序混乱,引发数据异常或死循环等问题。
下面是一个基于Java语言的简单Demo,展示原子性、可见性和有序性:
package com.example.democrud.democurd.test01; public class SynchronizedDemo { private volatile int count = 0; // 声明为volatile,保证可见性 /** * synchronized关键字保证了increment()方法的原子性和有序性, * 即一个线程获得了该方法的锁以后,其他线程必须等待该线程执行完毕后才能执行该方法。 */ public synchronized void increment() { count++; // 这里的操作是原子性的 } public static void main(String[] args) throws InterruptedException { SynchronizedDemo demo = new SynchronizedDemo(); for (int i = 0; i < 100; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { demo.increment(); // 多个线程对count进行1000次自增操作 } }).start(); } Thread.sleep(5000); // 等待所有子线程执行完毕 System.out.println(demo.count); // 输出结果应该是100000,因为这里的操作都是同步的,并且可以保证线程安全 } }
上述Demo中,使用synchronized关键字保证了increment()方法的原子性和有序性。同时,将count变量声明为volatile,确保了可见性。多个线程对count进行1000次自增操作后,输出结果应该是100000,因为这里的操作都是同步的,并且可以保证线程安全。
扩展1:
volatile解释理解
volatile是Java中的一个关键字,它的作用是保证多线程之间对于共享变量的可见性和禁止指令重排序优化。 在Java程序中,每个线程都有自己的工作内存,其中包含了该线程所需要访问的一部分共享变量的副本。如果一个线程修改了某个共享变量的值,这个修改操作可能会被延迟到主内存中,其他线程就无法立即看到这个修改的结果,从而可能导致数据不一致的问题。 使用volatile关键字可以解决这个问题。当一个变量被声明为volatile时,每次访问它时都会直接读取主内存中的最新值,而不是从线程的工作内存中读取。同时,JVM也会禁止指令重排序优化,保证了代码执行的有序性,从而避免了多线程并发执行时出现意外情况。 需要注意的是,volatile不能完全替代synchronized关键字,它只能保证对单个变量的原子性操作,而无法保证复合操作的原子性。如果要保证复合操作的原子性,仍然需要使用synchronized关键字来进行同步控制。
SynchronizedDemo 示例代码说明:
public class SynchronizedDemo { private int count = 0; // 在实例方法上使用synchronized关键字 public synchronized void increment() { count++; } // 在代码块上使用synchronized关键字 public void demoMethod() { synchronized (this) { // todo } } }
在上述示例中,increment() 方法和 demoMethod() 方法都使用了 synchronized 关键字来保证线程安全。
二、synchronized的使用方式
synchronized的3种使用方式:
修饰实例方法:作用于当前实例加锁;
修饰静态方法:作用于当前类对象加锁;
修饰代码块:指定加锁对象,对给定对象加锁。
1.修饰方法
Synchronized修饰一个方法很简单,就是在方法的前面加synchronized,synchronized修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。
方法一:修饰的是一个方法
public synchronized void method() { // todo }
方法二:修饰的是一个代码块
public void method() { synchronized(this) { // todo } }
方法一与方法二是等价的,都是锁定了整个方法时的内容。
synchronized
关键字不能继承。虽然可以使用synchronized
来定义方法,但synchronized
并不属于方法定义的一部分,因此,synchronized
关键字不能被继承。
在子类方法中加上synchronized
关键字
class Parent { public synchronized void method() { } } class Child extends Parent { public synchronized void method() { } }
在子类方法中调用父类的同步方法
class Parent { public synchronized void method() { } } class Child extends Parent { public void method() { super.method(); } }
注意:
在定义接口方法时不能使用synchronized
关键字。
构造方法不能使用synchronized
关键字,但可以使用synchronized
代码块来进行同步。
当有一个明确的对象作为锁时,就可以用类似下面这样的方式写程序:
public void method3(SomeObject obj) { //obj 锁定的对象 synchronized(obj) { // todo } }
当没有明确的对象作为锁,只是想让一段代码同步时,可以创建一个特殊的对象来充当锁:
class Test implements Runnable { private byte[] lock = new byte[0]; // 特殊的instance变量 public void method() { synchronized(lock) { // todo 同步代码块 } } public void run() { } }
这段代码中,通过创建一个特殊的byte数组作为锁对象,实现了对某一段同步代码块的互斥访问。
在Java中,每个对象都有一个内置的监视器(monitor),可以用于实现对该对象的同步控制。当一个线程试图进入同步代码块时,它需要先获得该代码块关联的对象的监视器,如果此时该对象的监视器已经被其他线程占用,则当前线程会进入阻塞状态,直到获取到该对象的监视器为止。
上面这段代码中,通过创建一个特殊的byte数组,然后将其作为锁对象关联到同步代码块上。由于每个byte数组都是唯一的,因此不会出现多个线程占用同一个锁对象的情况。这样就能够保证代码块的互斥访问了。
需要注意的是,这种方式只适合于小范围的代码块同步,如果需要同步的代码块过大,容易导致锁竞争而影响性能。在实际编码中,建议使用更加灵活和可读性更高的方式,如使用内置锁(synchronized)或者重入锁(ReentrantLock)。
2.修饰一个静态方法
synchronized也可修饰一个静态方法,用法如下:
public synchronized static void method() { // todo }
3.修饰一个类
Synchronized还可作用于一个类,用法如下:
class ClassName { public void method() { synchronized(ClassName.class) { // todo } } }
使用总结
- 无论
synchronized
关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized
作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。 - 每个对象只有一个锁(
lock
)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。 - 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。
示例代码说明:
public class SynchronizedDemo { private static int count = 0; // 在静态方法上使用synchronized关键字 public synchronized static void increment() { count++; } // 在代码块上使用synchronized关键字 public void demoMethod(Object lock) { synchronized (lock) { // todo } } }
在上述示例中,increment
() 方法和 demoMethod
() 方法分别演示了修饰静态方法和代码块时使用 synchronized
关键字的方式。
三、synchronized的底层实现
synchronized
关键字是基于进入和退出monitor
对象来实现方法同步和代码块同步。在Java虚拟机(HotSpot
)中,每个对象都有一个monitor对象与之关联,而monitor
是由ObjectMonitor
实现的。
谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。
对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
- 对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
synchronized
用的锁就是存在Java对象头里的,那么什么是Java对象头呢?Hotspot
虚拟机的对象头主要包括两部分数据:Mark Word
(标记字段)、Class Pointer
(类型指针)。其中 Class Pointer
是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word
用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。
监视器(Monitor)
任何一个对象都有一个Monitor
与之关联,当且一个Monitor
被持有后,它将处于锁定状态。
synchronized
在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter
和MonitorExit
指令来实现。
MonitorEnter
指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;
MonitorExit
指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter
必须有对应的MonitorExit
;
那什么是Monitor
?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor
,每一个Java对象都有成为Monitor的潜质,因为在Java
的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor
锁。
也就是通常说Synchronized
的对象锁,MarkWord
锁标识位为10,其中指针指向的是Monitor
对象的起始地址。在Java虚拟机(HotSpot
)中,Monitor
是由ObjectMonitor
实现的。
public class SynchronizedDemo { private int count = 0; // 在实例方法上使用synchronized关键字 public synchronized void increment() { count++; } }
四、synchronized 锁的升级顺序
- 锁是用来保证多线程并发执行时数据安全的,但是加锁会带来一定的性能开销。为了减少加锁和解锁的开销,在JDK1.6之后,Java引入了偏向锁、轻量级锁、自旋锁和重量级锁等概念,提高了锁的效率。
- 锁的状态有四种,从低到高分别为:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
- 在多线程竞争不激烈的情况下,锁可能会处于偏向锁状态,即锁会偏向于某个线程,这样该线程再次获取锁时可以直接获得,避免了不必要的竞争。
- 当多个线程竞争同一个锁时,锁会升级为轻量级锁状态,此时线程会尝试通过自旋来获取锁,避免了上下文切换的开销。如果自旋失败,锁就会进一步升级为重量级锁状态,此时线程会阻塞等待获取锁,保证了数据的安全性。但是锁的升级是单向的,也就是说锁只会从低到高升级,不会降级。同时,随着锁状态的升级,开销也会逐渐加大。
- 总之,在多线程编程中,合理运用锁的各种状态可以提高程序的并发性能和数据安全性。但是需要根据实际情况灵活选择不同类型的锁,并注意避免死锁等问题;
public class LockDemo { public static void main(String[] args) throws InterruptedException { Object obj = new Object(); // 创建一个对象作为同步监视器 // 无锁状态 System.out.println("无锁状态:"); for (int i = 0; i < 5; i++) { new Thread(() -> { System.out.println(Thread.currentThread().getName() + " running..."); }).start(); } Thread.sleep(1000); // 等待所有子线程执行完毕 // 偏向锁状态 System.out.println("偏向锁状态:"); synchronized (obj) { // 第一次获取锁 for (int i = 0; i < 5; i++) { new Thread(() -> { synchronized (obj) { // 多个线程再次获取锁 System.out.println(Thread.currentThread().getName() + " running..."); } }).start(); } } Thread.sleep(1000); // 等待所有子线程执行完毕 // 轻量级锁状态 System.out.println("轻量级锁状态:"); synchronized (obj) { // 第一次获取锁 new Thread(() -> { synchronized (obj) { // 多个线程再次获取锁 System.out.println(Thread.currentThread().getName() + " running..."); try { Thread.sleep(5000); // 模拟耗时操作 } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); Thread.sleep(1000); // 等待子线程获取锁并进入同步块中 new Thread(() -> { synchronized (obj) { // 多个线程再次获取锁 System.out.println(Thread.currentThread().getName() + " running..."); } }).start(); } Thread.sleep(6000); // 等待子线程执行完毕 // 重量级锁状态 System.out.println("重量级锁状态:"); for (int i = 0; i < 5; i++) { new Thread(() -> { synchronized (obj) { // 多个线程竞争同一个锁 System.out.println(Thread.currentThread().getName() + " running..."); try { Thread.sleep(5000); // 模拟耗时操作 } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } Thread.sleep(10000); // 等待子线程执行完毕 } }
四种状态锁如下:
无锁状态:多个线程可以同时访问共享资源,不需要加锁。适用于读密集型场景,如缓存、只读数据库等。
偏向锁状态:当只有一个线程访问共享资源时,该线程可以获得偏向锁,不需要加锁和解锁。适用于单线程操作的场景。
轻量级锁状态:当多个线程尝试获取同一把锁时,会先尝试使用轻量级锁,避免进入重量级锁状态。适用于竞争不激烈的场景。
重量级锁状态:当多个线程竞争同一个锁且未能成功获取时,会进入重量级锁状态,此时会阻塞线程进行等待,直到获得锁之后才能继续执行。适用于竞争激烈的场景。
以上四种状态锁各有优缺点,应根据实际使用场景选择合适的类型。无锁状态可以提高并发性能,但可能存在数据不一致问题;偏向锁状态可以减少锁冲突,但可能无法适应高并发场景;轻量级锁状态可以避免进入重量级锁状态,但可能会因为自旋等待导致CPU资源浪费;重量级锁状态可以确保数据一致性,但会降低并发性能。
以上文章来源1;其中也加入自己的一些理解和网上查询的一些语法;帮助大家更好的理解入门吧!