java 偏向锁的作用_synchronized关键字的作用、原理以及锁优化

synchronized简介

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以隐式地扮演一个用于同步的锁的角色。这些Java内置的使用者看不到的锁被称为内部锁(intrinsic locks),也叫作监视器锁(monitor locks)。

线程在进入synchronized代码块会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。

拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用该内置锁资源的wait系列方法时释放该锁。

内部锁在Java中扮演了互斥锁(mutual exclusion lock,也称作mutex)的角色,意味着至多只有一个线程可以拥有锁,当线程A尝试请求一个被线程B占有的锁时,线程A必须等待或者阻塞,直到B释放它。如果B永远不释放锁,A将永远等下去。

除了用于线程同步、确保线程安全外,关键字synchronized还可以保证线程间的可见性和有序性。

synchronized 使用的三种方式修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

实例1:修饰代码块:

public class AccountingSync implements Runnable{

static final AccountingSync instance = new AccountingSync();

static int i = 0;

@Override

public void run() {

for (int j = 0; j < 10000000; j++) {

synchronized (instance) {

i++;

}

}

}

}

上述代码将synchronized作用于一个给定对象instance,因此,每次当线程进入关键字synchronized包裹的代码段,就都会要求请求instance实例的锁。如果当前有其他线程正持有这把锁,那么新到的线程就必须等待。这样,就保证了每次只能有一个线程执行i++操作。

示例2:修饰实例方法:

public class AccountingSync2 implements Runnable{

static final AccountingSync2 instance = new AccountingSync2();

static int i = 0;

public synchronized void increase(){

i++;

}

@Override

public void run() {

for (int j = 0; j < 10000000; j++) {

increase();

}

}

}

public static void main(String[] args) throws InterruptedException {

//显示创建线程,两个线程都指向同一个Runnable接口实例(instance对象) Thread t1 = new Thread(instance);

Thread t2 = new Thread(instance);

t1.start();

t2.start();

t1.join();

t2.join();

}

上述代码中,关键字 synchronized 作用于一个实例方法。就是说在进入increase()方法前,线程必须获得当前对象实例的锁,在本例中就是instance。

这里特定写出了main()方法,在main()方法,我们显示的创建了两个线程,并且将两个线程都指向同一个Runnable接口实例(instance对象)。这样才能保证两个线程在工作时,能够关注到同一个对象锁上去,从而保证线程安全。

错误示例2.1:

public class AccountingSync2 implements Runnable{

static final AccountingSync2 instance = new AccountingSync2();

static int i = 0;

public synchronized void increase(){

i++;

}

@Override

public void run() {

for (int j = 0; j < 10000000; j++) {

increase();

}

}

public static void main(String[] args) throws InterruptedException {

//错误用法 Thread t1 = new Thread(new AccountingSync2);

Thread t2 = new Thread(new AccountingSync2);

t1.start();

t2.start();

t1.join();

t2.join();

}

}

示例3:修饰静态方法

错误示例2.1可以简单的修改一下就可以正常工作,将increase()方法修改如下:

public static synchronized void increase(){

i++;

}

这样,即使两个线程指向不同的Runnable对象,也可以正确同步。因为方法快请求的是当前类的锁,而非当前实例。

synchronized底层原理

修改代码块

public class SynchronizedDemo {

public void method() {

synchronized (this) {

System.out.println("synchronized 代码块");

}

}

}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class。

synchronized 同步语句块经过Javac编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。

根据《Java虚拟机规范》的要求,在执行 monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

修饰方法的情况

public class SynchronizedDemo2 {

public synchronized void method() {

System.out.println("synchronized 方法");

}

}

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

JDK1.6之后的锁优化

synchronized 是Java语言中的一个重量级的操作,在主流Java虚拟机实现中,Java的线程时映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被 synchronized修饰的getter()或setter()方法),状态转消耗的时间甚至会比用户代码本身执行的时间还要长。

JDK5升级到JDK6,HotPot虚拟机开发团队在这个版本花了大量资源去实现各种锁优化技术,如适应性自旋锁(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了提升锁的效率。

偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

偏向锁的核心思想是如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了程序性能。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳,因为这时偏向模式会失效。使用Java虚拟机参数-XX:+UseBiasedLocking可以开启偏向锁。

要理解偏向锁以及后面的轻量级锁的原理,必须了解HotSpot虚拟机的对象头信息。HotSpot虚拟机的对象头分为两部分第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据的长度在32位和64位的Java虚拟机中分别占用32个或64个比特,官方称它为“Mark Word”。这部分是实现偏向锁和轻量级锁的关键。

第二部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。

这里重点介绍Mark Word,它被设计成一个非固定的动态数据结构,以尽可能的减少占用空间。锁的状态不同,存储的内容也不同。以下是不同状态下对象头的存储内容示意图:

(图源《深入理解Java虚拟机》)

当锁对象第一次被线程获取的时候,虚拟机会把对象头的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式,同时使用CAS操作把获得这个锁的线程的ID记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程以后在此进入这个锁的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁以及对Mark Word的更新操作等。)

一旦有另外一个线程来尝试获取这个锁,偏向模式马上就会结束。根据锁对象目前是否处于被锁定状态决定是否撤销偏向(偏向模式设置为“0”)。撤销后标志位恢复到未锁定(标志位为“01”或轻量级锁定(标志位为“00”)的状态)。

轻量级锁

如果偏向锁失败,虚拟机也不会立即挂起线程,它还会使用一种称为轻量级锁的优化手段。

区别于重量级锁使用操作系统互斥量来实现,轻量级锁的加锁和解锁都使用CAS来完成。

轻量级锁的加锁

线程在执行同步块之前,JVM会先在当前线程的桢栈中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,将对象Mark Word的锁标志位转变为“00”,表示此对象处于轻量级锁状态。

如果失败,表示其他线程抢先争夺到了锁,轻量级锁就不再有效,膨胀为重量级锁,并尝试使用自旋来获取锁。

轻量级锁解锁

轻量级锁解锁时,会使用CAS操作将Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁正在竞争,锁就会膨胀成重量级锁。

轻量级锁能提升性能的依据是“对于绝大部分锁,在整个同步周期内都不存在竞争”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作避免了使用互斥量的开销;但如果存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而比传统的重量级锁更慢。

自旋锁和自适应自旋

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

挂起和恢复线程的操作都需要转入内核态中完成,如果加锁失败便简单粗暴的挂起线程可能得不偿失。因为虚拟机的开发团队注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间。为了这段时间去挂起和恢复线程并不值得。

这时,虚拟机会让没请求到锁的线程做几个空循环(自旋),这时线程并不会放弃处理器的执行时间,,在经过若干次循环后,如果可以得到锁,那便进入同步块。

自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过--XX:+UseSpinning参数来开启。JDK1.6及1.6之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改--XX:PreBlockSpin来更改。

另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚成功获得过锁,并且持有锁的线程正在运行,那么虚拟机就会认为这次自旋也很可能再次成功,进而允许自旋等待更长的时间。另一方面,如果对于某个锁,自旋很少成功获得锁,那以后要获取这个锁时可能直接省略掉自旋过程。

锁消除

锁消除是一更加彻底的优化,指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

可是,既然不存在竞争,为什么程序员还要加锁呢?其实有许多同步措施并不是程序员自己加入的,而是一些内置的API,框架加入的。比如下面的代码:

public String[] concatString() {

Vector vector = new Vector<>();

for (int j = 0; j < 100; j++) {

vector.add(Integer.toString(i));

}

return vector.toArray(new String[]{});

}

在这段代码中,变量vector只在concatString()方法中使用,它是局部变量。局部变量是在线程栈上分配的,属于线程私有的数据,不会被其他线程访问到。在这种情况下,Vector内部所有加锁同步都是没有必要的,这时虚拟机会将这些无用的锁操作去除。

锁消除的主要判定依据是逃逸分析,就是观察某一个变量是否会逃出某一个作用域,如果判断到一段代码中,在堆上所有数据都不会逃逸出去被其他线程访问到,那就可以把他们当做栈上数据对待,认为它们是线程私有的,同步加锁自然不用进行。

逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。使用-XX:+EliminateLocks参数可以打开锁消除。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽可能小,这样可以让同步操作所需的时间尽可能少,即使存在锁竞争,等待锁的线程也能尽可能块地拿到锁。

大多数情况下,上面的原则是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那会导致不必要的性能损耗。

如果虚拟机探测到这样的操作,就会把加锁同步的返回扩展(粗化)到整个操作序列的外部,这样只需要加锁一次就可以了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值