java基础06-多线程(3)
文章目录
一、synchronized详解
synchronized 依赖于操作系统的互斥锁,与对象是一对一关系。
synchronized的作用
-
确保线程互斥的访问同步代码 在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。
-
保证共享变量的修改能够及时可见
必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。
-
有效解决重排序问题。
synchronized的用法
-
修饰普通方法,锁对象默认为this,同一对象共用一把锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27import java.util.concurrent.TimeUnit; public class Test{ public static void main(String[] args) { MRunnable m = new MRunnable(); Thread t1 = new Thread(m,"线程一"); Thread t2 = new Thread(m,"线程二"); t1.start(); t2.start(); } } class MRunnable implements Runnable{ @Override public void run() { method(); } public synchronized void method () { try { System.out.println(Thread.currentThread().getName()+" 开始"); TimeUnit.SECONDS.sleep(2); System.out.println(Thread.currentThread().getName()+" 结束"); } catch (InterruptedException e) { e.printStackTrace(); } } } -
修饰静态方法,synchronized作用于整个类,所有对象共用一把锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28import java.util.concurrent.TimeUnit; public class Test{ public static void main(String[] args) { MRunnable m = new MRunnable(); MRunnable m1 = new MRunnable(); Thread t1 = new Thread(m,"线程一"); Thread t2 = new Thread(m1,"线程二"); t1.start(); t2.start(); } } class MRunnable implements Runnable{ @Override public void run() { method(); } public synchronized static void method () { try { System.out.println(Thread.currentThread().getName()+" 开始"); TimeUnit.SECONDS.sleep(2); System.out.println(Thread.currentThread().getName()+" 结束"); } catch (InterruptedException e) { e.printStackTrace(); } } } -
修饰代码块,synchronized(this)同一对象共用一把锁,synchronized(*.class)所有对象共用一把锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30import java.util.concurrent.TimeUnit; public class Test{ public static void main(String[] args) { MRunnable m = new MRunnable(); MRunnable m1 = new MRunnable(); Thread t1 = new Thread(m,"线程一"); Thread t2 = new Thread(m1,"线程二"); t1.start(); t2.start(); } } class MRunnable implements Runnable{ @Override public void run() { method(); } public void method () { synchronized (MRunnable.class) { try { System.out.println(Thread.currentThread().getName()+" 开始"); TimeUnit.SECONDS.sleep(2); System.out.println(Thread.currentThread().getName()+" 结束"); } catch (InterruptedException e) { e.printStackTrace(); } } } }
使用synchronized注意作用范围,是同一对象共用一把锁还是所有对象共用一把锁
Synchronized原理
synchronized的原理涉及到Java对象头和Monitor锁。每个Java对象在内存中都有一个对象头,其中包含了一些标记信息,如锁状态、线程拥有者等。Monitor锁是与Java对象头关联的一种底层锁机制,用于实现对象的互斥访问。
当线程进入synchronized方法或代码块时,它会尝试获取对象的Monitor锁。如果锁没有被其他线程占用,则该线程成功获取锁并继续执行。如果锁已经被其他线程占用,线程将被阻塞,直到获取到锁为止。
当线程执行完synchronized方法或代码块后,会释放对象的Monitor锁,让其他线程有机会获取锁并执行。这样就实现了线程的同步和互斥访问。
1. 同步代码块
当一个方法被声明为synchronized时,它将被称为同步方法。同步方法在执行时会自动获取当前对象的锁,只有获得锁的线程才能执行该方法,其他线程需要等待锁释放才能执行。
通过反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的
javac Test.java —> javap -verbose Test.class
|
|
反编译结果
JVM指令分析
monitorenter:互斥入口
monitorexit:互斥出口(monitorexit有两个,一个是正常出口,一个是异常出口)
-
monitorenter 每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态。 线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
-
monitorexit 执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
2. 同步方法
使用synchronized关键字可以创建临界区(也称为同步块)。同步块由一对花括号包围,其中指定了需要同步的对象。同一时刻只有一个线程能够进入同步块,并且在执行完同步块中的代码后释放锁,使其他线程有机会进入。
从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,
如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。
在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
3. Synchronized的可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。
|
|
二、volatile详解
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。
一个变量被volatile关键字修饰之后有两个作用:
(1)对于写操作:对变量更改完之后,要立刻写回到主存中。
(2)对于读操作:对变量读取的时候,要从主存中读,而不是缓存。
volatile 可见性实现
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。
lock 前缀的指令在多核处理器下会引发两件事情:
-
它会强制将对缓存的修改操作立即写入主存;
-
写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
这样,其他线程使用缓存时,发现本地工作内存中此变量无效,便从主内存中获取,这样获取到的变量便是最新的值,实现了线程的可见性。
volatile 有序性实
在JVM底层volatile是采用“内存屏障”来实现的(内存屏障其实就是一个CPU指令,在硬件层面上来说可以分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障)。
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止重排序。
为了实现 volatile 内存语义(即内存可见性),JMM 会限制特定类型的编译器和处理器重排序。为此,JMM 针对编译器制定了 volatile 重排序规则表,如下所示:
| 是否可以重排序 | 第二个操作 | ||
| 第一个操作 | 普通读/写 | volatile读 | volatile写 |
| 普通读/写 | YES | YES | NO |
| volatile读 | NO | NO | NO |
| volatile写 | YES | NO | NO |
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
- 在每个volatile写操作的前面插入一个StoreStore 屏障,后面插入一个StoreLoad 屏障。
- 在每个volatile读操作的前面插入一个LoadLoad 屏障,后面插入一个LoadStore 屏障。
在JVM层面上来说作用与上面的一样,但是种类可以分为四种:
| 内存屏障 | 说明 |
|---|---|
| StoreStore 屏障 | 禁止上面的普通写和下面的 volatile 写重排序。 |
| StoreLoad 屏障 | 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。 |
| LoadLoad 屏障 | 禁止下面所有的普通读操作和上面的 volatile 读重排序。 |
| LoadStore 屏障 | 禁止下面所有的普通写操作和上面的 volatile 读重排序。 |
文章作者 necor 上次更新 2022-10-21