解密Java中神奇的Synchronized关键字-LMLPHP

解密Java中神奇的Synchronized关键字-LMLPHP

解密Java中神奇的Synchronized关键字-LMLPHP

在Java中,当多个线程同时访问同一块代码,会产生竞态条件,可能会导致数据不一致或其他问题。为了解决这个问题,Java提供了synchronized关键字,它能够保证同一时刻被synchronized修饰的代码最多只有1个线程执行。本文将从synchronized的定义、JDK6以前的实现方式、偏向锁和轻量级锁、锁优化、synchronized关键字的用法和注意事项等方面详细讲解。

🎉 定义

在Java中,synchronized关键字是一种同步锁,在多线程编程中,用于解决多个线程同时访问同一个资源的问题。当一个线程持有锁时,其他线程将会被阻塞,直到当前线程释放锁为止。synchronized可以加在方法上或对象上,作用的对象是非静态的,则取得的锁是对象锁;作用的对象是静态方法或类,则取到的锁是类锁,这个类所有的对象用的是同一把锁。

🎉 JDK6以前

在JDK6以前,synchronized加锁是通过对象内部的监视器锁来实现的,监视器锁本质上又是依赖于底层的操作系统的Mutex Lock来实现的,因此在高并发情况下,synchronized的性能就会变得非常低下。

当多个线程争夺同一个锁时,会发生线程阻塞和唤醒的操作,这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要比较长的时间,导致程序执行效率低下。

🎉 偏向锁和轻量级锁

为了提高synchronized的效率,JDK6引入了偏向锁和轻量级锁。

📝 偏向锁

在Java中,同步操作需要获取锁来保证多个线程访问共享资源的安全性。而在锁的获取过程中,JVM添加了一种优化方式——偏向锁。当一个线程访问同步块时,如果这个同步块没有被其他线程占用,那么JVM会把这个同步块的锁标记为偏向锁,并把当前线程ID记录在MarkWord中。这样,下次同一线程访问同步块时,就不需要再次获取锁,直接进入同步块即可。

但是,当有其他线程来竞争锁的时候,偏向锁需要进行撤销,转而使用轻量级锁或者重量级锁来保证同步操作的安全性。这里给出一个对象从无锁到偏向锁转化的过程:

第一步,检测MarkWord是否为可偏向状态,即锁标识位是01,如果是,则说明当前对象可被偏向,可以执行同步代码块。

第二步,如果需要访问同步块的线程ID与MarkWord中记录的线程ID相同,则不需要进行竞争,直接执行同步代码块即可。

第三步,如果需要访问同步块的线程ID与MarkWord中记录的线程ID不同,说明该对象已经被其他线程占用,需要进行竞争。此时,JVM会进行CAS操作,如果操作成功,则将线程ID替换为当前线程ID,并执行同步代码块。

第四步,如果CAS操作失败,则需要进行偏向锁的撤销。这个过程可以有两种情况触发:一是对象头的Epoch字段计数到一定次数,二是多个线程尝试竞争该对象的锁,都失败了。

第五步,完成偏向锁的撤销后,持有偏向锁的线程不会被挂起,继续执行同步代码块。如果获取锁失败,则视情况进行自旋或者进行阻塞等待,进一步升级为轻量级锁或重量级锁。

需要注意的是,在偏向锁撤销的过程中,需要清除那些曾经持有该偏向锁对象的线程的锁记录。这是因为在偏向锁状态下,持有锁的线程会在对象头中记录一个标记位和持有该锁的线程ID。而在撤销偏向锁的过程中,需要清除这些锁记录,因为它们已经不再持有该锁,以便其他线程可以重新争夺锁的所有权。并且,偏向锁撤销的过程不一定会挂起所有持有偏向锁的线程,只有在线程竞争锁时才会挂起线程。

在偏向锁撤销过程中,JVM会启动偏向锁撤销线程来遍历所有持有该偏向锁对象的线程栈,清除它们的锁记录。而在多线程编程中,当多个线程对一个内存位置进行读取和修改时,可能会出现一种情况——ABA问题。为了解决这个问题,JVM 在对象的内存布局中添加了一个Epoch字段,来判断一个线程是否因为ABA问题导致的线程变化。这个Epoch字段并不直接关系到偏向锁撤销的过程,但是有助于判断锁的状态是否发生了变化。

在实际的应用中,偏向锁的优化方式能够显著提高同步代码块的性能,但它并不适用于所有场景。在多线程应用程序中,如果存在大量的锁竞争,那么偏向锁的优化效果会下降,甚至被轻量级锁或重量级锁取代。因此,在使用偏向锁的时候,需要根据具体情况进行考虑和使用。同时,了解偏向锁的撤销过程,有助于我们更好地理解同步机制的底层实现,更好地进行多线程编程。

📝 轻量级锁

轻量级锁升级过程是为了实现对象的互斥访问,首先在当前线程的栈帧中创建一个锁记录用于存储锁对象的MarkWord的拷贝。该拷贝无锁状态对象头中的MarkWord,用于在申请对象锁时作为CAS的比较条件。同时也能通过这个比较判定是否在持有锁的过程中,这个锁被其他线程申请过,如果有,在释放锁的时候要唤醒被挂起的线程。轻量级锁的MarkWord如果存有hashCode,解锁后也需要恢复。拷贝成功后,虚拟机使用CAS操作把对象中对象头的MarkWord替换为指向锁记录的指针,再把锁记录空间里的owner指针指向加锁的对象。如果这个更新操作成功,那么当前线程就拥有了该对象的锁,并且对象MarkWord的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果更新操作失败,虚拟机会检查对象的MarkWord中的Lock Word是否指向当前线程的栈帧。如果是,则当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行。如果不是,则说明多个线程竞争锁,要进入自旋。若自旋结束时依然未获得锁,轻量级锁就要升级为重量级锁,锁标志的状态值变为“10”,对象MarkWord中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后续等待锁的线程也要进入阻塞状态。

当锁升级为轻量级锁后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。一般来说,同步代码块内的代码应该很快就执行结束,这时线程B自旋一段时间是很容易拿到锁的。但如果不巧,没能拿到锁,自旋就会成为死循环,并且耗费CPU。因此,虚拟机会直接将锁升级为重量级锁,不再进行自旋。这样就不需要了线程一直自旋,性能会得到很大的提升。

📝 自旋锁

自旋锁并不是一种锁状态,而是一种智能的线程同步策略。它可以用于保护临界区的并发访问,避免多个线程同时进入临界区导致的数据不一致问题。自旋锁的核心思想是,在等待锁的过程中,不会主动阻塞线程,而是继续执行当前线程内的代码,同时不断自旋等待锁的释放,以减少线程的阻塞和唤醒,提高并发性能。

当一个线程尝试获取某个锁时,如果发现该锁已经被其他线程占用,则该线程不会被立即挂起或者睡眠,而是开始自旋等待锁的释放。自旋等待期间,该线程会一直占用CPU处理器资源,循环检测锁是否被释放。自旋等待不能替代阻塞,因为如果自旋时间过长,会占用过多CPU资源,反而降低性能。

自旋锁适用于维护临界区很小的情况。临界区很小表示锁占用的时间很短,如果持有锁的线程很快就能释放锁,那么自旋的效率就会非常高。但是自旋的次数必须要有一个限度,如果自旋超过了定义的次数仍然没有获取到锁,就应该被挂起。然而这个限度不能固定,因为程序锁的状况是不可预估的,所以JDK1.6引入了自适应的自旋锁,可以根据程序运行的情况动态调整自旋的次数。比如如果线程自旋成功了,那么下次自旋的次数会更多,反之则会更少,从而避免了自旋等待过程中浪费处理器资源的情况。

要开启自旋锁,可以使用JDK1.6之后提供的参数–XX:+UseSpinning,如果需要修改自旋次数,可以使用–XX:PreBlockSpin参数来指定,其中默认值为10次。使用自旋锁可以在一定程度上提高多线程程序的性能,但也需要注意合理设置自旋次数和使用范围,以免造成过多的CPU资源占用和线程的饥饿等问题。

📝 重量级锁

重量级锁是Java中的一种锁,是通过对象内部的监视器锁(Monitor)来实现的。监视器锁本质上又是依赖于底层的操作系统的MutexLock来实现的。由于操作系统实现线程之间的切换需要从用户态转换到核心态,状态之间的转换需要比较长的时间,因此依赖于操作系统MutexLock所实现的锁我们称之为“重量级锁”。

当一个线程在等待锁时,会不停的进行自旋,其中自旋的线程数达到CPU核数一半之后,就会升级为重量级锁。在升级为重量级锁之后,锁标志会被置为10,同时将MarkWord中的指针指向重量级的Monitor,这将阻塞所有没有获取到锁的线程。

重量级锁的加锁-等待-撤销流程分为三个步骤。

🔥 1. 加锁

当一个线程请求锁时,首先会查看锁标志是否为0,如果为0,则表示锁没有被占用,此时该线程会将锁标志置为1,并且获取到锁。如果锁标志不为0,则表示锁已经被占用,此时该线程会进入自旋状态。

🔥 2. 等待

在自旋状态下,如果锁一直没有被释放,那么自旋的线程数量会不断增加。当自旋的线程数量达到CPU核数的1/2时,就会升级为重量级锁。在升级为重量级锁之后,会将锁标志置为10,同时将MarkWord中的指针指向重量级的Monitor,这将阻塞所有没有获取到锁的线程。

🔥 3. 撤销

当一个线程释放锁时,会将锁标志置为0。此时正在等待的线程会被唤醒并争夺锁,曾经获得过锁的线程,在被唤醒之后会优先得到锁。如果一个线程在等待锁的过程中调用了wait()方法,则该线程会被加入到等待队列中,并通过wait_set等待被唤醒。如果一个线程在等待锁的过程中调用了notify()方法,则该线程会将等待队列中的第一个线程唤醒,等待队列中被唤醒的线程会被加入到同步队列中,并通过park()方法等待获取锁。

当重量级锁撤销之后,系统会将其转变为无锁状态。撤销锁之后会清除创建的Monitor对象,并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。当GC清除掉Monitor对象之后,锁就被撤销为无锁状态。

重量级锁适用于多线程互斥访问同一资源的情况。由于重量级锁性能较低,因此只在必要时才应该使用。在Java中,Synchronized关键字就是一种重量级锁。在使用Synchronized关键字时,应尽量减少锁的持有时间,这样可以提高程序的并发性能。

🎉 锁优化

针对于synchronized的性能问题和在某些情况下可能导致死锁的情况,Java提供了以下的锁优化:

📝 锁消除

在编译器层面消除不必要的加锁操作,将锁的范围缩小到最小,这样可以减少锁竞争的概率,提高程序的执行效率。

📝 锁粗化

在进行一系列操作时,将多个连续的加锁操作放在一个代码块中,这样可以减少加锁和解锁的开销,提高程序的执行效率。

📝 自适应自旋

当线程尝试获取同步锁失败后,它并不会立即进入阻塞状态,而是再次尝试获取同步锁,如果一段时间内失败的次数越多,就会进入阻塞状态。这个时间段就是自旋时间,是由操作系统动态调整的。

🎉 synchronized关键字的用法和注意事项

synchronized关键字的用法有以下几种:

📝 修饰方法

这种方式是修饰整个方法,即使方法中没有同步代码块,也会锁定这个方法,这种方式适用于整个方法需要同步的情况。

public synchronized void method() {
    // 同步代码块
}
📝 修饰代码块

这种方式是将同步代码块包在synchronized括号内,只有在执行到synchronized代码块时才会锁定,这种方式适用于只需要同步执行部分代码的情况。

public void method() {
    synchronized (this) {
        // 同步代码块
    }
}
📝 修饰静态方法

和修饰方法类似,这种方式是锁定整个静态方法,适用于整个静态方法需要同步的情况。

public synchronized static void method() {
    // 同步代码块
}
📝 修饰类

这种方式是锁定整个类,即使不同实例中的线程也会被锁定,适用于整个类需要同步的情况。

public void method() {
    synchronized (ClassName.class) {
        // 同步代码块
    }
}

需要注意的是,只有在多个线程访问同一块资源时,才需要使用synchronized关键字。如果同步块内的代码很少,那么锁的代价就会超过同步块内的代码的执行代价,从而导致程序执行效率变低。同时,在使用synchronized关键字时,需要考虑死锁问题,即多个线程无限制地等待对方释放锁的情况。因此,在编写代码时,需要特别注意同步块的范围和锁的粒度。

解密Java中神奇的Synchronized关键字-LMLPHP

解密Java中神奇的Synchronized关键字-LMLPHP

10-24 08:46