只有共享资源的读写访问才需要同步化。

一、synchronized的三种应用方式

  • 修饰实例方法,作用于当前实例加锁。
  • 修饰静态方法,作用于当前类对象加锁。
  • 修饰代码块,指定加锁对象,对给定对象加锁。 | | 锁作用的对象 | 备注 | | —- | —- | —- | | 修饰实例方法 | 当前实例 | | | 修饰静态方法 | 当前类,给Class上锁 | Class锁可以对类的所有对象实例起作用 | | 修饰代码快 | 给指定对象 | |

1.1、修饰实例方法

  1. /*一个同步方法可以调用另外一个同步方法。一个线程已经拥有某个对象的锁,再次申请的时候任然会得到该对象的锁,即synchronized获得的锁是可重入的。*/
  2. public class SynMethodCallAnother {
  3. synchronized void m1() {
  4. System.out.println("m1 start...");
  5. try {
  6. TimeUnit.SECONDS.sleep(1);
  7. } catch (InterruptedException e) {
  8. e.printStackTrace();
  9. }
  10. m2();
  11. }
  12. synchronized void m2() {
  13. try {
  14. TimeUnit.SECONDS.sleep(2);
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. System.out.println("m2");
  19. }
  20. }

当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized实例方法。

注:锁自身等于修饰实例方法

  1. public class SynchronizeThis {
  2. private int count = 10;
  3. public void m1() {
  4. synchronized (this) { //任何线程要执行下面的代码,必须先拿到this的锁
  5. count -- ;
  6. System.out.println(Thread.currentThread().getName() + "count = " + count);
  7. }
  8. }
  9. //m1()等同于下面的m2()
  10. public synchronized void m2() { //等同于在方法的代码执行时要synchronized(this)
  11. count -- ;
  12. System.out.println(Thread.currentThread().getName() + "count = " +count);
  13. }
  14. }

image.png

1.2、修饰静态方法

由于synchronized关键字修饰的是静态increase方法,与修饰实例方法不同的是,其锁对象是当前类的class对象。

  1. package cn.xx55xx.concurrence;
  2. /*类中静态方法和静态属性属性是不需要new出对象来访问的,没有new出来,就没有this引用的存在,
  3. 所以当锁定一个静态方法时,相当于锁定的是当前类的class对象*/
  4. public class SynchronizedStaticMethod {
  5. private static int count = 10;
  6. //等同于synchronized(cn.xx55xx.concurrence.SynchronizedStaticMethod.class)
  7. public synchronized static void m1() {
  8. count --;
  9. System.out.println(Thread.currentThread().getName() + "count = " + count);
  10. }
  11. public static void m2() {
  12. synchronized (SynchronizedStaticMethod.class) { //*.class是Class中的一个对象,这里是不能用synchronized(this)的
  13. count -- ;
  14. }
  15. }
  16. }

1.3、修饰代码块

修饰实例方法有可能导致的弊端,就是锁住的区域太大,导致性能下降。

二、出现异常,默认情况下锁会被释放

  1. /** 程序执行过程中,如果出现异常,默认情况锁会被释放,所以在并发处理的过程中,有异常要多加小心,不然会发生不一致的情况;
  2. * 比如,在一个web application处理请求时,多个servlet线程共同访问同一个资源,这时如果异常处理不合适;
  3. * 在第一个线程中抛出异常,其他线程就会进入同步代码区,有可能会访问到异常产生的数据,因此要非常小心地处理同步业务逻辑中的异常。*/
  4. public class SynchronizedException {
  5. int count = 0;
  6. synchronized void m(){
  7. System.out.println(Thread.currentThread().getName() + " start");
  8. while(true) {
  9. count ++ ;
  10. System.out.println(Thread.currentThread().getName() + " count = " +count);
  11. try {
  12. TimeUnit.SECONDS.sleep(1);
  13. } catch (InterruptedException e) {
  14. e.printStackTrace();
  15. }
  16. if (count == 5) {
  17. int i = 1/0; //此处抛出异常,锁将会被释放。要想锁不被释放,可以在这里进行catch,然后循环继续
  18. }
  19. }
  20. }
  21. public static void main(String[] args) {
  22. SynchronizedException se = new SynchronizedException();
  23. Runnable r = new Runnable() {
  24. @Override
  25. public void run() {
  26. se.m();
  27. }
  28. };
  29. new Thread(r, "t1").start();
  30. try {
  31. TimeUnit.SECONDS.sleep(2);
  32. } catch (InterruptedException e) {
  33. e.printStackTrace();
  34. }
  35. new Thread(r,"t2").start();
  36. }
  37. }

三、synchronized拥有锁重入的功能

四、继承与synchronized

4.1、同步不具有继承性

4.2、在继承中,子类重写的同步方法可以调用父类的同步方法

  1. /*一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到锁的对象,即可重入的。
  2. 在继承中,子类同步方法可以调用父类的同步方法*/
  3. public class CallSuperclassSynMethod {
  4. synchronized void m() {
  5. System.out.println("m start...");
  6. try {
  7. TimeUnit.SECONDS.sleep(1);
  8. } catch (InterruptedException e) {
  9. e.printStackTrace();
  10. }
  11. System.out.println("m end");
  12. }
  13. public static void main(String[] args) {
  14. new Child().m(); //锁定的都是同一个对象(子类对象)
  15. }
  16. }
  17. class Child extends CallSuperclassSynMethod {
  18. @Override
  19. synchronized void m() {
  20. System.out.println("child m start");
  21. super.m();
  22. System.out.println("child m end");
  23. }
  24. }

五、Java对象头与Monitor

上面提到,当线程进入synchronized方法或者代码块时需要先获取锁,退出时需要释放锁。那么这个锁信息到底存在哪里呢?其实这个锁是存在对象的对象头中的。
Java对象保存在内存中时,由以下三部分组成:

  • 对象头
  • 实例数据
  • 对齐填充字节

而对象头又由下面几部分组成:

  • Mark Word
  • 指向类的指针
  • 数组长度(只有数组对象才有)

5.1、 Mark Word
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

锁状态 25bit 4bit 1bit 2bit
23bit 2bit 是否偏向锁 锁标志位
无锁 对象的HashCode 分代年龄 0 01
偏向锁 线程ID Epoch 分代年龄 1 01
轻量锁 指向栈中锁记录的指针 00
重量锁 指向重量级锁的指针 10
GC标记 11

5.2、指向类的指针
该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。Java对象的类数据保存在方法区。
5.3、 数组长度
只有数组对象保存了这部分数据。该数据在32位和64位JVM中长度都是32bit。

六、synchronized对锁的优化

6.1、偏向锁

Java 6之前的synchronized会导致争用不到锁的线程进入阻塞状态,线程在阻塞状态和runnbale状态之间切换是很耗费系统资源的,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁。为了缓解上述性能问题,Java 6开始,引入了轻量锁与偏向锁,默认启用了自旋,他们都属于乐观锁
偏向锁更准确的说是锁的一种状态。在这种锁状态下,系统中只有一个线程来争夺这个锁。线程只要简单地通过Mark Word中存放的线程ID和自己的ID是否一致就能拿到锁。下面简单介绍下偏向锁获取和升级的过程。

当系统中还没有访问过synchronized代码时,此时锁的状态肯定是“无锁状态”,也就是说“是否是偏向锁”的值是0,“锁标志位”的值是01。此时有一个线程1来访问同步代码,发现锁对象的状态是”无锁状态”,那么操作起来非常简单了,只需要将“是否偏向锁”标志位改成1,再将线程1的线程ID写入Mark Word即可。
如果后续系统中一直只有线程1来拿锁,那么只要简单的判断下线程1的ID和Mark Word中的线程ID,线程1就能非常轻松地拿到锁。但是现实往往不是那么简单的,现在假设线程2也要来竞争同步锁,我们看下情况是怎么样的。

  • step1:线程2首先根据“是否是偏向锁”和“锁标志位”的值判断出当前锁的状态是“偏向锁”状态,但是Mark Word中的线程ID又不是指向自己(此时线程ID还是指向线程1),所以此时回去判断线程1还是否存在;
  • step2:假如此时线程1已经不存在了,线程2会将Mark Word中的线程ID指向自己的线程ID,锁不升级,仍为偏向锁;
  • step3:假如此时线程1还存在(线程1还没执行完同步代码,【不知道这样理解对不对,姑且先这么理解吧】),首先暂停线程1,设置锁标志位为00,锁升级为“轻量级锁”,继续执行线程1的代码;线程2通过自旋操作来继续获得锁。

在JDK6中,偏向锁是默认启用的。它提高了单线程访问同步资源的性能。但试想一下,如果你的同步资源或代码一直都是多线程访问的,那么消除偏向锁这一步骤对你来说就是多余的。事实上,消除偏向锁的开销还是蛮大的。
所以在你非常熟悉自己的代码前提下,大可禁用偏向锁:

  1. -XX:-UseBiasedLocking=false

6.2、轻量级锁

“轻量级锁”锁也是一种锁的状态,这种锁状态的特点是:当一个线程来竞争锁失败时,不会立即进入阻塞状态,而是会进行一段时间的锁自旋操作,如果自旋操作拿锁成功就执行同步代码,如果经过一段时间的自旋操作还是没拿到锁,线程就进入阻塞状态。
1、轻量级锁加锁流程
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
2、轻量级锁解锁流程
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

6.3、重量级锁

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

6.4、锁自旋(轻量级锁在获取锁时会进行自旋)

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,线程不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗!但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取到cpu,造成cpu的浪费。
JDK7之后,锁的自旋特性都是由JVM自身控制的,不需要我们手动配置。

6.5、锁对比

优点 缺点 使用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序响应速度 如果始终得不到锁竞争的线程,使用自选会消耗CPU 追求响应时间
同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量
同步块执行速度较长

这边做下说明:

  • 关于偏向锁的使用场景,并不是说系统中一直只有一个线程在执行同步代码,而是说同一时刻只有一个线程来争抢同步代码的锁;
  • 关于轻量级锁的使用场景,可以有多个线程来争抢锁,但是每个线程占用锁的时间非常短,这样其他争抢的线程只要等待一下(自旋)就能获取锁,而不需要进入block状态引起上下文切换。

    6.6、锁的优化

  • 减少锁的时间:不需要同步的代码不加锁;

  • 使用读写锁:读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;
  • 锁粗化:假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

参考资料

  1. https://blog.csdn.net/javazejian/article/details/72828483