并发编程_同步工具_2_synchronized关键字

104 阅读15分钟

synchronized基础

synchronized是java中非常重要的关键字,理解它对理解同步问题非常重要。下面整理一下synchronized的关键点,方便以后的复习。

1、synchronized锁定的到底是什么

synchronized无论用在方法上还是同步代码块锁定的都一定是对象。

同步方法___锁定this

public class T {

   private int count = 10;
   
   public synchronized void m() { //等同于在方法的代码执行时要synchronized(this)
      count--;
      System.out.println(Thread.currentThread().getName() + " count = " + count);
   }

}

静态方法___锁定当前类的class对象

 值得注意的是,静态方法里面是不可以锁定this的,因为静态方法属于类。jvm虚拟机栈的地方已经学过,静态方法的局部变量
 表不持有this对象。
public class T {

   private static int count = 10;
   
   public synchronized static void m() { //这里等同于synchronized(yxxy.c_004.T.class)
      count--;
      System.out.println(Thread.currentThread().getName() + " count = " + count);
   }
   
   public static void mm() {
      synchronized(T.class) { //考虑一下这里写synchronized(this)是否可以?
         count --;
      }
   }
}

锁定对象或锁定代码块

public class T {
   
   private int count = 10;
   private Object o = new Object();
   
   public void m() {
      synchronized(o) { //任何线程要执行下面的代码,必须先拿到o的锁
         count--;
         System.out.println(Thread.currentThread().getName() + " count = " + count);
      }
   }
}

2、synchronizd保障原子性

 下面的代码在不加synchronizeded情况下count未必能正确减到5,因为发生在这个变量上的操作不是一个原子操作。
 synchronized可以在此处保证count--操作的正确性。
public class T implements Runnable {

   private int count = 10;
   
   public /*synchronized*/ void run() { 
      count--;
      System.out.println(Thread.currentThread().getName() + " count = " + count);
   }
   
   public static void main(String[] args) {
      T t = new T();
      for(int i=0; i<5; i++) {
         new Thread(t, "THREAD" + i).start();
      }
   }
   
}

3、使用synchronizd可能引起脏读问题

对业务写方法加锁
对业务读方法不加锁
容易产生脏读问题(dirtyRead)
那么怎么解决呢?思路有很多,其一就是给getBalance方法也加上synchronized关键字
public class Account {
   String name;
   double balance;
   
   public synchronized void set(String name, double balance) {
      this.name = name;
      
      try {
         Thread.sleep(2000);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      
      
      this.balance = balance;
   }
   
   public  double getBalance(String name) {
      return this.balance;
   }
   
   
   public static void main(String[] args) {
      Account a = new Account();
      new Thread(()->a.set("zhangsan", 100.0)).start();
      
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      
      System.out.println(a.getBalance("zhangsan"));
      
      try {
         TimeUnit.SECONDS.sleep(2);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      
      System.out.println(a.getBalance("zhangsan"));
   }
}
后续咱们将采取copyOnWrite解决脏读问题

4、synchronized是可重入锁

一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到该对象的锁.
也就是说synchronized获得的锁是可重入的
public class T {
   synchronized void m1() {
      System.out.println("m1 start");
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      m2();
   }
   
   synchronized void m2() {
      try {
         TimeUnit.SECONDS.sleep(2);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      System.out.println("m2");
   }
}

5、发生异常synchronized默认释放锁的问题

程序在执行过程中,如果出现异常,默认情况锁会被释放
所以,在并发处理的过程中,有异常要多加小心,不然可能会发生不一致的情况。
比如,在一个web app处理过程中,多个servlet线程共同访问同一个资源,这时如果异常处理不合适,
在第一个线程中抛出异常,其他线程就会进入同步代码区,有可能会访问到异常产生时的数据。
因此要非常小心的处理同步业务逻辑中的异常
public class T {
   int count = 0;
   synchronized void m() {
      System.out.println(Thread.currentThread().getName() + " start");
      while(true) {
         count ++;
         System.out.println(Thread.currentThread().getName() + " count = " + count);
         try {
            TimeUnit.SECONDS.sleep(1);
            
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         
         if(count == 5) {
            int i = 1/0; //此处抛出异常,锁将被释放,要想不被释放,可以在这里进行catch,然后让循环继续
            System.out.println(i);
         }
      }
   }
   
   public static void main(String[] args) {
      T t = new T();
      Runnable r = new Runnable() {

         @Override
         public void run() {
            t.m();
         }
         
      };
      new Thread(r, "t1").start();
      
      try {
         TimeUnit.SECONDS.sleep(3);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      
      new Thread(r, "t2").start();
   }
   
}

6、volatile以及synchronized保证可见性、原子性

volatile可以保证变量的可见性但并不能保障发生在变量上的操作的原子性。
对于下面这个程序来说,下面的写法t1线程永远没有结束执行的机会,虽然在main方法中running变量被更新为false,
但是t1线程并不会从主存中重新获取running变量的值。
public class T {
   boolean running = true; 
   void m() {
      System.out.println("m start");
      while(running) {
      }
      System.out.println("m end!");
   }
   
   public static void main(String[] args) {
      T t = new T();
      new Thread(t::m, "t1").start();
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      t.running = false;
   }
   
}
解决这个问题的方式非常多
1、加volatile关键字
2、利用线程sleep触发系统中断,使得线程重新load变量值
3、使用原子类型的AtomicBoolean代替runnning,atomicxxx类既可以保障可见性,也可以保障原子性
sleep可以避免问题的原因是,系统得以中断,cpu得到了一些空余时间,没事做可能就重新去主存刷新了一下值
public class T {
   boolean running = true;
   void m() {
      System.out.println("m start");
      while(running) {
         try {
            TimeUnit.MILLISECONDS.sleep(10);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
      }
      System.out.println("m end!");
   }
   
   public static void main(String[] args) {
      T t = new T();
      
      new Thread(t::m, "t1").start();
      
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      
      t.running = false;
      
      
   }
   
}
volatile 关键字,使一个变量在多个线程间可见
A B线程都用到一个变量,java默认是A线程中保留一份copy,这样如果B线程修改了该变量,则A线程未必知道
使用volatile关键字,会让所有线程都会读到变量的修改值
在下面的代码中,running是存在于堆内存的t对象中
当线程t1开始运行的时候,会把running值从内存中读到t1线程的工作区,在运行过程中直接使用这个copy,
并不会每次都去读取堆内存,这样,当主线程修改running的值之后,t1线程感知不到,所以不会停止运行

使用volatile,将会强制所有线程都去堆内存中读取running的值
volatile并不能保证多个线程共同修改running变量时所带来的不一致问题,也就是说volatile
不能替代synchronized

volatile可以保证读的时候没问题 但不保证最后的修改操作有类似cas修改的特质

volatile此处的含义是,当这个变量在主存中被修改了的时候
要去volatile会通知有关这个变量的其它线程,你们的告高速缓存中的这个变量的值已经过期了
请重新去读一下
public class T {
   volatile boolean running = true; //对比一下有无volatile的情况下,整个程序运行结果的区别
   void m() {
      System.out.println("m start");
      while(running) {
      }
      System.out.println("m end!");
   }
   
   public static void main(String[] args) {
      T t = new T();
      
      new Thread(t::m, "t1").start();
      
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      
      t.running = false;
      
      
   }
   
}
执行下面的程序,理想状态下count应该被加到100000,但是运行会发现并不能,原因就在于m方法中的count++
并非是一个原子操作。尽管count变量加了volatile修饰,但是依旧存在以下情形:
count的值变为99,此时线程A和线程B均得到通知去主存中重新获取了count的最新值99,但是AB都执行加1A把count加为100写回主存,B也把count加为100写回主存。这种错误volatle无法避免,因此它不能保障操作的原子性。

image.png


public class T {
   volatile int count = 0; 
   void m() {
      for(int i=0; i<10000; i++) {
         count++;
      }
   }
   
   public static void main(String[] args) {
      T t = new T();
      
      List<Thread> threads = new ArrayList<Thread>();
      
      for(int i=0; i<10; i++) {
         threads.add(new Thread(t::m, "thread-"+i));
      }
      
      threads.forEach((o)->o.start());
      
      threads.forEach((o)->{
         try {
            o.join();
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
      });
      
      System.out.println(t.count);
      
      
   }
   
}
 此时想保证变量的原子性,要使用synchronized
public class T {
   volatile int count = 0; 
   synchronized void m() {
      for(int i=0; i<10000; i++) {
         count++;
      }
   }
   
   public static void main(String[] args) {
      T t = new T();
      
      List<Thread> threads = new ArrayList<Thread>();
      
      for(int i=0; i<10; i++) {
         threads.add(new Thread(t::m, "thread-"+i));
      }
      
      threads.forEach((o)->o.start());
      
      threads.forEach((o)->{
         try {
            o.join();
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
      });
      
      System.out.println(t.count);
      
      
   }
   
}

7、一些注意事项

1、锁定某对象o,如果o的属性发生改变,不影响锁的使用。但是如果o变成另外一个对象,则锁定的对象发生改变
应该避免将锁定对象的引用变成另外的对象

2、
不要以字符串常量作为锁定对象
在下面的例子中,m1和m2其实锁定的是同一个对象
这种情况还会发生比较诡异的现象,比如你用到了一个类库,在该类库中代码锁定了字符串“Hello”,
但是你读不到源码,所以你在自己的代码中也锁定了"Hello",这时候就有可能发生非常诡异的死锁阻塞,
因为你的程序和你用到的类库不经意间使用了同一把锁

synchronized底层原理

a、对象在内存中的存储布局

经常的,咱们会创建一个对象,如Object o =new Object(); 或者创建一个对象数据,Object[] objectArray = new Object[3] 等。

对象在内存中的存储有自己的一套规则,出生在堆区的java对象或对象数组有如下图所示的存储布局:

1659683726581.png

markword,标记字,8bytes.

class pointerr,4个字节,指向对象对应的.class类型,显然,这应该和方法区有联系。

实例数据即成员变量。

对齐padding,最终要求整个对象的大小能被8字节整除。

基本类型占用内存的大小:

类型
byte1
short2
boolean1
char2
int4
float4
long8
double8
数组size占4个字节,加上实例数据大小
引用类型开启指针压缩为4,不开启为8

b、synchronized在1.8环境的升级流程

synchronized锁的升级如下所示:

1659693881045.png

对于64位的虚拟机来讲,对象头的第一个字节的最后3bit标识这个对象的锁状态或GC情况

1659774373792.png

在java中观察对象的内存布局可以使用JOL工具进行

不加锁的情况下有如下输出: image.png

如果增加synchronized关键字:

image.png

看到这里大家可能存在疑惑,不应该上来先启动偏向锁吗,为什么直接就是轻量级锁了呢?原因在于main方法启动的虚拟机上来就是多线程环境,无必要启动偏向锁,直接升级为轻量级锁。如果想看到偏向锁,可以使得主线程沉睡4秒以上,再观察,如下:

image.png

JVM虚拟机自己有一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。

-XX:BiasedLockingStartupDelay=0
此参数可设置虚拟机启动多久后开启偏向锁。

b-1、匿名偏向

image.png

c、偏向锁的获取

偏向锁的获取是比较复杂的,涉及到的主要概念是是否为偏向锁模式?epoch是否有效?markword中记录的threadid是否和当前线程一致?

不同于其他锁,执行完毕就要释放锁,偏向锁的释放是在下一个线程竞争时才执行的锁撤销。
另外值得一提的是偏向锁的CAS操作并不是针对Mark Word中的某一个值进行替换,而是构建一个新的Mark Word替换旧的Mark Word。

详细内容就看图慢慢理解 image.png

c-1、偏向锁的优化:批量重偏向与批量锁撤销

c-2 批量重偏向

锁撤销需要到安全点才可以进行。但是如果一个代码块需要大量的加锁操作,就会导致一个线程持有大量的偏向锁,那么在其他线程执行这个代码块获取锁时就可能在安全点进行大量的锁撤销,这会使得偏向锁的性能急剧变差。为了应对这种情况,JVM在进行锁撤销时带有一个批量重偏向的优化机制。 在上面的Mark Word图中可以看到偏向锁状态时锁对象会用两个字节记录epoch,可以简单的认为这个epoch就是一个时间戳,除此之外锁对象对应的Class内部也维护一个epoch。一个新对象被创建时,Mark Word中的epoch和Class的epoch保持一致。 以Class为单位,每个Class会维护一个偏向锁撤销计数器,属于该Class的锁对象每进行一次锁撤销,Class内维护的计数器就+1,当达到阈值时(默认20,由JVM参数BiasedLockingBulkRebiasThreshold控制),就认为这个Class的所属的锁对象出现了问题,进行批量重偏向。 进行批量重偏向时,Class会生成一个新的epoch_new,并扫描所有该Class所属的对象,如果对象处于偏向状态且偏向线程运行在同步代码块,则将Mark Word中的epoch修改为epoch_new,如果偏向线程已经死亡或不在同步代码块内则不变,以此来确认该Class所属的对象哪些处于正常偏向,哪些已经处于偏向失效状态。之后其他线程获取这些锁对象时如果锁对象的epoch和Class中的epoch一样的话则走正常的偏向锁获取流程,不一样的话就能知道这个偏向锁已经失效,无需等待安全点可以直接通过CAS替换旧的Mark Word获取偏向锁。 注意锁撤销的次数计数是以Class为维度计算的,也就是某个Class的所有锁实例的撤销次数和达到阈值就会对该Class下的所有锁对象进行批量重偏向操作,而不是以单一一个对象为维度的。如:Lock a = new Lock();Lock b = new Lock(); 那么锁撤销次数=a的撤销次数+b的撤销次数。

c-3 批量锁撤销

进行批量重偏向后,撤销次数计数器依然会继续计数,如果锁撤销时发现撤销计数器达到40时(由JVM参数BiasedLockingBulkRevokeThreshold控制),就会进行批量锁撤销,JVM会在安全点将该Class所有锁实例的偏向锁全部撤销,膨胀为轻量级锁,并标记为禁止偏向锁,关闭该Class的偏向锁功能(Class内部有一个是否允许偏向锁的标识),上文也说过是否允许偏向锁由JVM参数与Class内的允许偏向标识共同决定。后续new出来的该Class实例会直接被禁用偏向锁,其他Class则不会受影响。 批量锁撤销也是以Class为维度,而不是以对象为维度。 批量锁撤销存在的意义就是频繁的锁撤销意味着线程在交替执行,显然更适合轻量级锁。

对于批量锁撤销和批量重偏向,背后有着它深刻地含义,如果一个对象经常被用来当做吧synchronized的锁对象,
并且经常在这个对象上发生线程争抢锁,那么后续就不必要再走偏向锁的流程了。直接走cas流程

c-4 锁定时间

由JVM参数BiasedLockingDecayTime控制,默认为25000。即批量重偏向之后超过25000ms还没有达到批量锁撤销的阈值,就重置撤销次数计数器。毕竟批量锁偏向和批量锁撤销都是为了应对短时间内线程的频繁切换而做的优化,如果两次线程切换时间段相隔很长,就是一次正常的锁撤销了。

c-5 偏向锁延迟

由JVM参数BiasedLockingStartupDelay控制,默认为4,即JVM启动的前4s不启用偏向锁,这是因为JVM刚启动时竞争比较激烈,并不适合偏向锁,因此跳过这一阶段。

c-6 关闭偏向锁

由JVM参数UseBiasedLocking控制,JDK1.6之后默认为true,即启用。

c-7 identityHashCode对偏向锁的影响

如果你没有重写对象的HashCode方法,使用的是Object的native int hashCode(),那么返回的hashCode就是identityHashCode,即使重写了HashCode,也可以通过System.identityHashCode(Object x)获得identityHashCode。也许你可以重写hashCode()方法让对象在不同状态返回不同的Hash值,但是一个对象的identityHashCode一经计算就永远不会改变,无锁状态时MarkWord中存的就是identityHashCode,identityHashCode会在第一次计算时存入MarkWord,后续再获取identityHashCode就会直接返回这个,而重写的HashCode并不会存入MarkWord。这也是为什么都说对象的identityHashCode和地址相关,但是明明对象会因为GC而移动改变存储位置,返回的identityHashCode却永远相同,就是因为identityHashCode只与它第一次计算时的地址有关,后续返回的都是存在MarkWorde的identityHashCode值。 通过上面的MarkWord存储内容图,可以看到无锁状态下identityHashCode和偏向状态下偏向线程ID+Epoch的位置是重叠的,这意味着同时只能存在一个,因此如果线程处于偏向锁状态时如果计算了identityHashCode就必须退出偏向状态。计算identityHashCode时(无论是通过Object.hashcode()还是System.identityHashCode(Object x))发现Object处于偏向状态,就会检测是否处于加锁状态即是否在同步代码块中,如果不处于加锁状态则退化为无锁状态并存储identityHashCode,如果处于加锁状态,则会直接膨胀为重量级锁。值得注意的是匿名偏向也是不处于的一种,因此如果创建一个对象是匿名偏向状态,然后计算了identityHashCode,这个对象就会变成无锁状态,再加锁会进入轻量锁的逻辑。 概括起来一句话,一个对象一旦计算了identityHashCode,那么这个对象就和偏向锁无缘了。值得一提的是Object默认的toString方法也会调用hashCode()方法。 那为什么轻量级锁和重量级锁和identityHashCode存储位置重合的部分也存的有其他内容,他们却可以呢?这个是因为轻量级锁的Lock Record中的Displaced Mark word记录的就是对象无锁状态时的MarkWord,而重量级锁的ObjectMonitor中也有属性存储MarkWord的相关信息。也就是轻量级锁和重量级锁把对象的identityHashCode存在了其他位置。为什么偏向锁不这么做,因为偏向锁面向的就是较为简单的场景,没有必要这样做变的更复杂起来,而且一般情况下加锁对象也不会计算identityHashCode,当然是越简单越好。