synchronized关键字:多线程资源访问同步性与早期效率低下原因
目录1.1 说一说自己对于关键字的了解
关键字的作用是解决多个线程访问资源的同步性问题。它能够确保被其修饰的方法或者代码块在任何时刻都只能有一个线程执行。
在 Java 的早期版本里,它属于重量级锁,效率较为低下。这是因为监视器锁()是依靠底层的操作系统的 Mutex Lock 来实现的,并且 Java 的线程是映射在操作系统的原生线程之上的。要挂起或者唤醒一个线程,都得依靠操作系统来帮忙完成。操作系统在实现线程之间的切换时,需要从用户态转换到内核态。这种状态之间的转换需要花费相对较长的时间,且时间成本较高。这就是早期效率低的原因所在。庆幸的是,在 Java 6 之后,Java 官方从 JVM 层面进行了较大优化。所以现在的锁效率优化得很不错了。JDK1.6 对锁的实现引入了诸多优化,像自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术,这些技术能减少锁操作的开销。
1.2 说说自己是怎么使用关键字,在项目中用到了吗
关键字最主要的三种使用方式:
下面我已一个常见的面试题为例讲解一下关键字的具体使用。
面试时面试官常常会问:“你了解单例模式吗?请给我手写一下单例模式!给我讲讲用双重检验锁方式实现单例模式的原理吧!”
双重校验锁实现对象单例(线程安全)
<p style='margin-bottom:15px;color:#555555;font-size:15px;line-height:200%;text-indent:2em;'> <pre class="has"><code class="language-java">public class Singleton {
private 类型的变量 uniqueInstance 是 volatile 的且为 static 的,它是 Singleton 类型的单例对象。
private Singleton() {
}
public static Singleton 获取唯一实例() {
先判断对象是否已经被实例化过,如果没有被实例化过,才进入加锁代码。
如果 uniqueInstance 为 null ,那么 ;如果 uniqueInstance 不为 null ,则 。
//类对象加锁
对 Singleton.class 进行同步操作。
如果 uniqueInstance 为 null ,那么 ;如果 uniqueInstance 不为 null ,则不执行此条件判断 。
Singleton 类创建了一个名为 uniqueInstance 的实例。
}
}
}
返回唯一实例。
}
}
</code></pre></p>
另外,需要注意采用关键字修饰也是很有必要。
使用关键字进行修饰是很有必要的。这段代码实际上是分三步来执行的:第一步,进行某个操作;第二步,执行另一个动作;第三步,完成最后的步骤。
https://img2.baidu.com/it/u=1040868314,2805631735&fm=253&fmt=JPEG&app=138&f=JPEG?w=500&h=562
为分配内存空间初始化 将指向分配的内存地址
但是 JVM 具备指令重排的特性,所以执行顺序有可能变为 1->3->2。在单线程环境中,指令重排不会出现问题,然而在多线程环境下,会致使一个线程获取到尚未初始化的实例。比如,线程 T1 执行了 1 和 3,这时 T2 调用某个对象后发现该对象不为空,于是就进行返回,但此时该对象还未被初始化。
使用它能够禁止 JVM 的指令重排,从而确保在多线程环境下可以正常运行。
1.3 讲一下关键字的底层原理
关键字底层原理属于 JVM 层面。
①同步语句块的情况
<p style='margin-bottom:15px;color:#555555;font-size:15px;line-height:200%;text-indent:2em;'> <pre class="has"><code class="language-java">类 SynchronizedDemo 是一个公共类。
public void method() {
synchronized (this) {
System 输出打印“synchronized 代码块”。
}
}
}
</code></pre></p>
使用 JDK 自带的 javap 命令来查看类的相关字节码信息。首先要切换到类的对应目录,接着在该目录下执行 javac.java 命令,以生成编译后的.class 文件。之后再执行 javap -c -s -v -l.class 命令。
关键字原理
从上面我们可以看出:
同步语句块的实现运用的是指令与指令。其中,指令所指的是同步代码块的起始位置,而指令所指明的是同步代码块的结束位置。当执行指令时,线程会尝试获取锁。锁存在于每个 Java 对象的对象头中,通过这种方式来获取锁,这也是 Java 中任意对象可以作为锁的原因。当锁的计数器为 0 时,线程可以成功获取锁,获取后将锁的计数器设为 1,即加 1。相应地,在执行指令后,会将锁的计数器设为 0,表明锁被释放。获取对象锁失败时,当前线程会进行阻塞等待。它会一直等待,直到锁被另一个线程释放。
②修饰方法的的情况
<p style='margin-bottom:15px;color:#555555;font-size:15px;line-height:200%;text-indent:2em;'> <pre class="has"><code class="language-java">在该类中可以看到对同步机制的运用和相关操作的实现。
public 方法处于同步状态。
System 输出打印 "synchronized 方法"。
}
}
</code></pre></p>
修饰的方法没有特定的指令。取而代之的是标识,此标识表明该方法是同步方法。JVM 通过这个访问标志来辨别一个方法是否被声明为同步方法,进而执行相应的同步调用。
说说 JDK1.6 之后的关键字在底层进行了哪些方面的优化呢?可以详细地介绍一下这些优化的情况吗?
JDK1.6 对锁的实现做了大量优化。其中包括引入偏向锁技术,以减少锁操作的开销。还引入了轻量级锁技术,同样能降低锁操作的开销。同时引入了自旋锁技术,有助于减少锁操作的开销。也引入了适应性自旋锁技术,可减少锁操作的开销。并且引入了锁消除技术,能减少锁操作的开销。还引入了锁粗化技术,可降低锁操作的开销。
锁主要有四种状态,分别是:无锁状态;偏向锁状态;轻量级锁状态;重量级锁状态。它们会随着竞争的加剧而逐步升级。需要注意的是,锁可以升级但不可降级,这种策略的目的是提升获得锁和释放锁的效率。
①偏向锁
引入偏向锁的目的与引入轻量级锁的目的较为相似。二者都是在没有多线程竞争的前提下,旨在减少传统重量级锁使用操作系统互斥量所带来的性能消耗。然而,二者存在差异:轻量级锁在无竞争的情况下,会利用 CAS 操作来替代使用互斥量;而偏向锁在无竞争的情况下,能够将整个同步过程消除掉。
https://img1.baidu.com/it/u=4037344851,11207713&fm=253&fmt=JPEG&app=138&f=JPEG?w=1057&h=500
偏向锁的“偏”即偏心之意,意味着会倾向于第一个获取它的线程。若在后续执行中,此锁未被其他线程获取,那么持有偏向锁的线程就无需进行同步。关于偏向锁的原理,可查看《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》第二版的 13 章第三节锁优化。
在锁竞争激烈的场合,偏向锁会失效。因为这种场合下,每次申请锁的线程很可能都不一样。所以在这种场合不应使用偏向锁,否则会得不偿失。需要注意的是,偏向锁失败后,不会立即膨胀为重量级锁,而是先升级为轻量级锁。
② 轻量级锁
如果偏向锁失败,虚拟机不会立刻升级为重量级锁。它会尝试使用一种优化手段,这种手段称为轻量级锁(1.6 之后加入的)。轻量级锁并非是为了替代重量级锁,其本意是在没有多线程竞争的情况下,减少传统重量级锁使用操作系统互斥量所产生的性能消耗,因为使用轻量级锁时,无需申请互斥量。另外,轻量级锁在加锁时用到了 CAS 操作,在解锁时也用到了 CAS 操作。关于轻量级锁加锁和解锁的原理,可以查看《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》第二版的 13 章第三节锁优化。
轻量级锁能提升程序同步性能的依据是绝大部分锁在整个同步周期内不存在竞争,这是经验数据。若没有竞争,轻量级锁利用 CAS 操作避免了互斥操作的开销。然而,若存在锁竞争,除了互斥量开销外,还会额外发生 CAS 操作,所以在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢。如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!
③ 自旋锁和自适应自旋
轻量级锁失败后,虚拟机为避免线程在操作系统层面真实挂起,还会进行一种被称为自旋锁的优化手段。
互斥同步对性能影响最大的方面是阻塞的实现。因为挂起线程的操作以及恢复线程的操作都需要转入内核态去完成,而用户态转换到内核态是会耗费时间的。
一般线程持有锁的时间不会太长。因为时间不长,仅仅为这点时间去挂起线程或恢复线程是不值得的。所以,虚拟机的开发团队进行了这样的考虑:能否让后面来请求获取锁的线程等待一会儿而不被挂起呢?看看持有锁的线程是否能很快释放锁。要让一个线程等待,只需让线程执行一个忙循环(自旋),而这种技术就被称作自旋。
百度百科对自旋锁的解释:
什么是自旋锁?自旋锁是为了实现对共享资源的保护而提出的一种锁机制。实际上,自旋锁和互斥锁较为相似,它们都是为了解决对某一项资源的互斥使用问题。不管是互斥锁,还是自旋锁,在任何一个时刻,最多只能有一个保持者,也就是说,在任何一个时刻,最多只能有一个执行单元获得锁。然而,两者在调度机制方面略有差异。互斥锁的情况是,如果资源已经被占用,那么资源申请者就只能进入睡眠状态。而自旋锁则不会导致调用者睡眠,倘若自旋锁已经被其他执行单元所保持,调用者就会一直循环,查看该自旋锁的保持者是否已经释放了锁,“自旋”这个词正是因为这样的情况而得名。
自旋锁在 JDK1.6 之前就已被引入,但其默认是关闭的,需通过--XX:+参数来开启。JDK1.6 及之后,就改为默认开启了。需注意,自旋等待不能完全替代阻塞,因其仍需占用处理器时间。若锁被占用时间短,效果就好;若锁被占用时间长,效果则不佳。自旋等待的时间必须有限度。如果自旋达到了限定的次数,然而仍然没有获得锁,那么就应当挂起线程。自旋的次数默认是 10 次,用户能够通过修改--XX:来对其进行更改。
另外,在 JDK1.6 中引入了一种自旋锁。这种自旋锁是自适应的。自适应的自旋锁带来的改进在于:自旋的时间不再是固定的了。它会根据前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。这样一来,虚拟机变得越来越“聪明”了。
④ 锁消除
锁消除理解起来较为容易。它的意思是,在虚拟机中,即使是编译器在运行时,如果检测到那些共享数据不可能存在竞争的情况,那么就会执行锁消除操作。锁消除能够节省那些没有意义的请求锁所耗费的时间。
⑤ 锁粗化
我们在编写代码时,原则上总是推荐把同步快的作用范围限制得尽量小。只在共享数据的实际作用域进行同步,这样做是为了让需要同步的操作数量尽可能变小。如果存在锁竞争,等待线程就能尽快拿到锁。
大部分情况下,上面的原则没问题。然而,若一系列连续操作都针对同一个对象反复进行加锁和解锁,就会导致很多不必要的性能消耗。
1.5 谈谈 和 的区别
① 两者都是可重入锁
两者均为可重入锁。“可重入锁”的概念为:自身能够再次获取自身的内部锁。例如,有一个线程获取了某对象的锁,而此时该对象锁尚未释放,当它再次想要获取此对象的锁时依然可以获取。倘若不可锁重入,就会引发死锁。同一个线程每次获取锁,锁的计数器都会自增 1,因此必须等到锁的计数器降为 0 时才能释放锁。
②依赖于 JVM 而依赖于 API
依赖于 JVM 得以实现。前面我们提到过,虚拟机团队在 JDK1.6 对该关键字进行了诸多优化,然而这些优化是在虚拟机层面完成的,并未直接呈现在我们面前。它是在 JDK 层面实现的,也就是在 API 层面实现的。实现过程需要 lock()方法和特定的语句块(如 try/ 语句块)配合来完成。因此,我们能够通过查看它的源代码,去了解它是怎样实现的。
③比增加了一些高级功能
相比之前,增添了一些较为高级的功能。主要体现在以下三个方面:其一,等待的过程可以被中断;其二,能够实现公平锁;其三,可以实现选择性通知,也就是锁能够绑定多个条件。
如果你想使用上述功能,那么选择是一个不错的选择。
④ 性能已不是选择标准
在 JDK1.6 之前, 的性能与 相比差很多。具体表现为:随着线程数的增加,关键字的吞吐量下降得极为严重,而 则基本保持在一个较为稳定的水平。我认为这从侧面反映出,关键字还有很大的优化空间。后续的技术发展也证实了这一点,我们之前讲过在 JDK1.6 之后,JVM 团队对该关键字做了很多优化。JDK1.6 之后, 和的性能大致相同。因此,网络上那些以性能为由选择的文章是错误的!JDK1.6 之后,性能不再是选择 和 的影响因素!并且,虚拟机在未来的性能改进中会更倾向于原生的,所以还是建议在能够满足需求的情况下,优先考虑使用关键字来进行同步!优化后的和一样,在很多地方都是用到了CAS操作。
1.6 说说关键字和关键字的区别
关键字和关键字比较
1.7 java.util.
j.u.c 下有大量的应用,这些应用在各个基础类和工具栏中,它们构成了 Java 并发包的基础。后续进行并发编程学习时,可以按照这个路线图来进行学习。
页:
[1]