Java 并发进阶知识之 synchronized 关键字
内容导读
互联网集市收集整理的这篇技术教程文章主要介绍了Java 并发进阶知识之 synchronized 关键字,小编现在分享给大家,供广大互联网技能从业者学习和参考。文章包含15176字,纯文字阅读大概需要22分钟。
内容图文
![Java 并发进阶知识之 synchronized 关键字](/upload/InfoBanner/zyjiaocheng/793/fb665952b9514b46837909fa69de306f.jpg)
synchronized 相关知识
1、synchronized 简介
synchronized 关键字解决的是多线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized 的以下几种最主要的使用方式:
(1)、同步一个代码块
public void func() { sychronized (this) { // ... } }
它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。
对于以下代码,使用 ExcutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句时,另个线程就必须等待!
public class SynchronizedExample { public void func1() { synchronized (this) { for (int i = 0; i < 10; i++) { System.out.print(i + " "); } } } }
public static void main(String[] args) { SynchronizedExample e1 = new SynchronizedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> e1.func1());// 用到了Lambda表达式 executorService.execute(() -> e1.func1()); }
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
对于以下代码,两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果看出,两个线程交叉。
public static void main(String[] args) { SynchronizedExample e1 = new SynchronizedExample(); SynchronizedExample e2 = new SynchronizedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> e1.func1()); executorService.execute(() -> e2.func1()); }
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9
(2)、同步一个方法
public synchronized void func () { // ... }
它和同步代码块一样,作用于同一个对象,进入同步代码前要获得当前对象实例的锁。
(3)、同步一个类
public void func() { synchronized (SynchronizedExample.class) { // ... } }
作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。
public class SynchronizedExample { public void func2() { synchronized (SynchronizedExample.class) { for (int i = 0; i < 10; i++) { System.out.print(i + " "); } } } }
public static void main(String[] args) { SynchronizedExample e1 = new SynchronizedExample(); SynchronizedExample e2 = new SynchronizedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> e1.func2()); executorService.execute(() -> e2.func2()); }
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
(4)、同步一个静态方法
public synchronized static void fun() { // ... }
作用于整个类,也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
综上:synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。synchronized 关键字加到静态方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!
下面以一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。
面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”
双重校验锁实现对象单例(线程安全)
public class Singleton { private volatile static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { //先判断对象是否已经实例过,没有实例化过才进入加锁代码 if (uniqueInstance == null) { //类对象加锁 synchronized (Singleton.class) { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } }
另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出先问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
2、synchronized 底层原理
synchronized 底层原理属于 JVM 层面。
① synchronized 同步代码块的情况
public class SynchronizedDemo { public void method() { synchronized (this) { System.out.println("synchronized 代码块"); } } }
通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 java Synchronized.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SychronizedDemo.class。
从上面可以看出:
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处, JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 是所有权 (monitor 对象存在于每个 Java 对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因),即尝试获得对象的锁。虚拟机会在执行这两个指令的时候会检查对象的锁状态是否为空或当前线程是否已经拥有该对象锁,如果是,则将对象锁的计数器加 1,直接进入同步代码执行;如果不是当前线程就要阻塞等待,等到锁释放。
② synchronized 修饰方法的情况
public class SynchronizedDemo2 { public synchronized void method() { System.out.println("synchronized 方法"); } }
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
为了减少获得锁和释放锁所带来的性能消耗。JDK1.6 中出现了轻量级锁,偏向锁,锁消除,适应性自旋锁,锁粗化(自旋锁在 JDK1.4 时就有了,只不过默认是关闭的,JDK1.6 是默认开启的),这些操作是为了在线程之间更高效的共享数据,解决竞争问题,主要优化 synchronized 的获取锁和释放锁的性能问题。
3、JDK1.6 后 synchronized 底层的优化。
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁与适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
(1)自旋锁与适应性自旋锁:
互斥同步对性能的最大影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能造成了很大的压力。其实在很多的应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。所以物理机器有一个以上的处理器,能让两个或以上线程同时并发执行,我们就可以让后面的线程“稍微等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是自旋。
自旋等待是不能代替阻塞的,自选等待本身虽然避免了线程切换的开销,但他是要占用处理器的时间的,因此,如果所被占用的时间很短,自旋等待的效果非常好,反之,如果所被占用的时间很长,呢么自旋的线程只会白白消耗处理器资源,而不作任何有用的工作,带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍没有成功获得锁,就应当使用传统的方式挂起线程了。
JDK1.6 引入了自适应自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
那自旋锁怎么实现?
如下代码(代码来源:https://blog.csdn.net/qq_34337272/article/details/81252853)
public class SpinLock { private AtomicReference<Thread> cas = new AtomicReference<Thread>(); public void lock() { Thread current = Thread.currentThread(); // 利用CAS while (!cas.compareAndSet(null, current)) { // DO nothing } } public void unlock() { Thread current = Thread.currentThread(); cas.compareAndSet(current, null); } }
lock() 方法利用的 CAS,当第一个线程 A 获取锁的时候,能够成功获取到,不会进入 while 循环,如果此时线程 A 没有释放锁,另一个线程 B 又来获取锁,此时由于不满足 CAS,所以就会进入 while 循环,不断判断是否满足 CAS,直到A线程调用 unlock 方法释放了该锁。
什么是 CAS ?
CAS 即 compare and swap (比较与替换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
CAS 实现原子操作的三大问题:
- ABA 问题:因为 CAS 需要操作值的时候会检查值有没有发生变化,如果没有,则更新。但是如果一个值原来是 A,变成了 B,又变成了 A,那么在使用 CAS 进行检查会发现值没有发生变化,但实际上发生了变化。
- 循环时间长开销大:自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
- 只能保证一个共享源自的操作:对一个共享变量操作的时候,可以使用 CAS 的方式保证原子性,但是对于多个,CAS 无法保证,此时可以用锁。
(2)锁消除:
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其他线程访问到,那么久可以把它们当成私有数据对待,也就可以将他们的锁进行消除。
对于一些看起来没有加锁的代码,其实隐式的加了很多锁。如下:
public static String concatString(String s1, String s2, String s3) { return s1 + s2 + s3; }
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:
public static String concatString(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
每个 StringBuffer.append() 方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。
(3)锁粗化:
原则上,我们在编写代码的时候,总是推荐将同步代码块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
上一节的示例代码中连续的 append() 方法就属于这种情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
(4)轻量级锁:
以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在下面的 state 表格中给出。
下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。
轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。
如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。
对于没有竞争的多线程,轻量级锁使用 CAS 操作避免了使用互斥量的开销,如果存在锁竞争,处理互斥量的开销外,还会额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
(5)偏向锁:
偏向锁的思想是偏向于第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 “01”。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
ReentrantLock 相关知识
ReentrantLock 是 java.util.concurrent 包中的锁。
public class LockExample { private Lock lock = new ReentrantLock(); public void func() { lock.lock(); try { for (int i = 0; i < 10; i++) { System.out.print(i + " "); } } finally { lock.unlock(); // 确保释放锁,从而避免发生死锁。 } } }
public static void main(String[] args) { LockExample lockExample = new LockExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> lockExample.func()); executorService.execute(() -> lockExample.func()); }
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
ReentrantLock 与 synchronized 比较
(1)、锁的实现
synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。前面提到的 synchronized 的优化,这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成,见实例代码),所以可以通过查看源代码来看怎么实现的。
(2)、ReentrantLock 比 synchronized 增加了一些高级功能
相比 synchronized,ReentrantLock 增加了一些高级功能。主要有三点:① 等待可中断;② 可实现公平锁;③ 可现实选择性通知(锁可以绑定多个条件)
- ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
- ReentrantLock可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。
- synchronized 关键字与 wait() 和 notify() / notifyAll() 方法相结合可以实现等待/通知机制,ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法。Condition 是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个 Lock 对象中可以创建多个 Condition 实例(即对象监视器),线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify() / notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现“选择性通知”,这个功能非常重要,而且是 Condition 接口默认提供的。而 synchronized 关键字就相当于整个Lock 对象中只有一个 Condition 实例,所有的线程都注册在它一个身上。如果执行 notifyAll() 方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而 Condition 实例的 signalAll() 方法只会唤醒注册在该 Condition 实例中的所有等待线程。
(3)、使用选择
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。
内容总结
以上是互联网集市为您收集整理的Java 并发进阶知识之 synchronized 关键字全部内容,希望文章能够帮你解决Java 并发进阶知识之 synchronized 关键字所遇到的程序开发问题。 如果觉得互联网集市技术教程内容还不错,欢迎将互联网集市网站推荐给程序员好友。
内容备注
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 gblab@vip.qq.com 举报,一经查实,本站将立刻删除。
内容手机端
扫描二维码推送至手机访问。