Java并发编程 之 Synchronized的深入理解
synchronized 简介
synchronized 关键字 : 代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
synchronized 关键字 包括两种用法:synchronized 方法和 synchronized 块。
synchronized 关键字 : 是Java语言的关键字, 可用来给对象和方法或者代码块加锁 ,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。
当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。
另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
然而,当一个线程访问object的一个加锁代码块时,另一个线程仍可以访问该object中的非加锁代码块。
为什么要使用synchronized?
在并发编程中存在 线程安全问题,主要原因有:
-
存在共享数据
-
多线程共同操作共享数据。
关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时synchronized可以保证一个线程的变化可见(可见性),即可以代替volatile。
synchronized 的实现原理
实现原理:
synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。
synchronized底层语义原理:
Java 虚拟机中的同步 ( Synchronization ) 基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。
在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。
同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED 标志来隐式实现的。
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。
JVM(虚拟机)可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。
如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
synchronized 的三种应用方式
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
-
普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁
-
静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁
-
同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
synchronized 的作用:
Synchronized是Java中解决并发问题的一种最常用最简单的方法 ,他可以确保线程互斥的访问同步代码
synchronized的三种应用方式:
-
synchronized作用于实例方法
① 多个线程访问同一个对象的同一个方法
代码测验:
public class synchronizedTest implements Runnable { //共享资源 static int i =0; /** * synchronized 修饰实例方法 */ public synchronized void increase(){ i++; } @Override public void run(){ for (int j =0 ; j<10000;j++){ increase(); } } public static void main(String[] args) throws InterruptedException { synchronizedTest test = new synchronizedTest(); Thread t1 = new Thread(test); Thread t2 = new Thread(test); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(i); } }
控制台效果::
解析:当两个线程同时对一个对象的一个方法进行操作,只有一个线程能够抢到锁。
因为一个对象只有一把锁,一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,就不能访问该对象的其他synchronized实例方法,但是可以访问非synchronized修饰的方法。② 一个线程获取了该对象的锁之后,其他线程来访问其他synchronized实例方法
代码测验:
public synchronized void method1() { System.out.println("Method 1 start"); try { System.out.println("Method 1 execute"); Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Method 1 end"); } public synchronized void method2() { System.out.println("Method 2 start"); try { System.out.println("Method 2 execute"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Method 2 end"); } public static void main(String[] args) { final syncTest test = new syncTest(); new Thread(new Runnable() { @Override public void run() { test.method1(); } }).start(); new Thread(new Runnable() { @Override public void run() { test.method2(); } }).start(); }
控制台效果:
分析:当其他线程来访问synchronized修饰的其他方法时需要等待线程1先把锁释放③ 一个线程获取了该对象的锁之后,其他线程来访问其他非synchronized实例方法
代码测验:
public synchronized void method1() { System.out.println("Method 1 start"); try { System.out.println("Method 1 execute"); Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Method 1 end"); } public void method2() { System.out.println("Method 2 start"); try { System.out.println("Method 2 execute"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Method 2 end"); } public static void main(String[] args) { final syncTest test = new syncTest(); new Thread(new Runnable() { @Override public void run() { test.method1(); } }).start(); new Thread(new Runnable() { @Override public void run() { test.method2(); } }).start(); }
控制台效果:
分析:当线程1还在执行时,线程2也执行了,所以当其他线程来访问非synchronized修饰的方法时是可以访问的④多个线程作用于不同的对象
代码测验:
//共享资源 static int i =0; /** * synchronized 修饰实例方法 */ public synchronized void increase(){ i++; } @Override public void run(){ for (int j =0 ; j<10000;j++){ increase(); } } public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new synchronizedTest()); Thread t2 = new Thread(new synchronizedTest()); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(i); }
控制台效果:
分析:因为两个线程作用于不同的对象,获得的是不同的锁,所以互相并不影响
拓展问题一:为什么分布式环境下synchronized失效?如何解决这种情况?
单机情况下,使用Syncronized或者lock实现锁机制,没啥问题,单进程多线程实现同步也没毛病。
但是,集群后synchronized失效是必然的,类似的同步操作必须转换为通过全局的同步互斥机制,例如数据库锁,这样会造成性能的急剧下降,如果系统有大量sychronized的处理不适合做集群。
这个时候java自带的锁就会实现不了并发功能了。
使用redis分布式锁或者zookeeper分布式锁就OK了! ~(o·ˇ▽ˇ·o) ~
-
synchronized作用于静态方法
代码测验:
public class synchronizedTest implements Runnable { //共享资源 static int i =0; /** * synchronized 修饰实例方法 */ public static synchronized void increase(){ i++; } @Override public void run(){ for (int j =0 ; j<10000;j++){ increase(); } } public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new synchronizedTest()); Thread t2 = new Thread(new synchronizedTest()); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(i); }
控制台效果:
分析:两个线程实例化两个不同的对象,但是访问的方法是静态的,两个线程发生了互斥(即一个线程访问,另一个线程只能等着),因为静态方法是依附于类而不是对象的,当synchronized修饰静态方法时,锁是class对象。 -
synchronized作用于同步代码块
为什么要同步代码块呢?
在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。代码测验:
public class synchronizedTest implements Runnable { static synchronizedTest instance=new synchronizedTest(); static int i=0; @Override public void run() { //省略其他耗时操作.... //使用同步代码块对变量i进行同步操作,锁对象为instance synchronized(instance){ for(int j=0;j<10000;j++){ i++; } } } public static void main(String[] args) throws InterruptedException { Thread t1=new Thread(instance); Thread t2=new Thread(instance); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(i); } }
分析:将synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了instance作为对象外,我们还可以 使用this对象(代表当前实例)或者当前类的class对象作为锁
代码测验:
//this,当前实例对象锁 synchronized(this){ for(int j=0;j<1000000;j++){ i++; } } //class对象锁 synchronized(AccountingSync.class){ for(int j=0;j<1000000;j++){ i++; } }
Java虚拟机对synchronized的优化
锁的状态总共有四种 : 无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级;
关于重量级锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,这里并不打算深入到每个锁的实现和转换过程更多地是阐述Java虚拟机所提供的每个锁的核心优化思想,毕竟涉及到具体过程比较繁琐,如需了解详细过程可以查阅《深入理解Java虚拟机原理》。
偏向锁:
偏向锁是Java 6之后加入的新锁,它是一种 针对加锁操作的优化手段 ,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。
但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
下面我们接着了解轻量级锁。
轻量级锁:
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。
轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。
需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
自旋锁:
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。
如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。
最后没办法也就只能升级为重量级锁了。
锁消除:
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,
如下:StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。