Java并发分析—synchronized
在计算机操作系统中,并发在宏观上是指在同一时间段内,同时有多道程序在运行。 一个程序可以对应一个进程或多个进程,进程有独立的存储空间。一个进程包含一个或多个线程。线程堆空间是共享的,栈空间是私有的。同样,在一个进程中,宏观上有多个线程同时运行。(微观上在单cup系统中,同一时刻,只有一个程序在运行。)
基于以上原理,线程在并发运行时,对共享数据的操作存在数据同步问题。
1.基本概念
1.什么样的数据会被存储在线程共享空间堆里?
对象,当使用new 关键字创建一个对象时,这个对象就被存储在堆里。
2.并发问题:
以一个例子来说明:
创建测试类Test.java,测试类中有一个方法对变量sum加1操作,创建两个线程tA和tB分别执行这段代码:
1 public class Test { 2 private int sum = 0; 3 public void add(){ 4 try { 5 System.out.println("线程:"+Thread.currentThread().getName()+"执行加1开始,sum当前值为:"+sum); 6 Thread.sleep(2000);//这两秒代表对其他数据进行操作所耗费的时间 7 sum++; 8 System.out.println("线程:"+Thread.currentThread().getName()+"执行加1结束,sum的值为:"+sum); 9 } catch (InterruptedException e) { 10 // TODO Auto-generated catch block 11 e.printStackTrace(); 12 } 13 } 14 public static void main(String [] args){ 15 final Test test = new Test(); 16 Thread tA = new Thread(new Runnable() { 17 18 @Override 19 public void run() { 20 // TODO Auto-generated method stub 21 test.add(); 22 } 23 }); 24 Thread tB = new Thread(new Runnable() { 25 26 @Override 27 public void run() { 28 // TODO Auto-generated method stub 29 test.add(); 30 } 31 }); 32 tA.start(); 33 tB.start(); 34 } 35 }
运行结果:
线程:Thread-1执行加1开始,sum当前值为:0 线程:Thread-0执行加1开始,sum当前值为:0 线程:Thread-1执行加1结束,sum的值为:1 线程:Thread-0执行加1结束,sum的值为:1
从以上数据来看,这个结果明显不对,两次加操作,最后的值应是2。
原因分析:两个线程先后执行add方法时,拿到的数据都是0,再对共享数据加1,最后结果都是1。
2.解决线程并发问题-synchronized
2.1 使用位置
1.普通同步方法,锁加在当前实例对象上。
2.静态方法,锁加载当前类对象上。
3.同步方法块,锁住的是synchronized (xxx)括号里的对象。
解析: 从1.0开始,java中的每一个对象都有一个内部锁(这是加锁的基础)。
对于普通同步方法,如果一个方法使用synchronized 关键字声明,那么对象锁将保护整个方法,作用的对象是调用这个方法的对象。当某一个线程运行到该方法时,需要检查有没有其他线程正在使用这个方法,有的话需要等待那个线程运行完这个方法后在运行,没有的话,需要锁定这个方法,然后运行。
对于静态方法声明为synchronized ,如果调用这种方法,因为静态方法是类方法,其作用的范围是整个方法,作用的对象是这个类的所有对象,该方法获得到相关的类对的内部锁,因此,没有其他线程可以调用同一个类的同步静态方法。
对于同步代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象,也就是括号里面的对象,synchronized 括号里可以反射获取类的对象,例如本示例中可以写成 synchronized (this),也可以写成Test.class 。
2.2 实现原理
2.2.1. 普通同步方法:
以上面代码为例,只需要给普通方法上加上synchronized 关键字,其他代码不变,只贴改变了的代码.
1 public synchronized void add(){ 2 try { 3 System.out.println("线程:"+Thread.currentThread().getName()+"执行加1开始,sum当前值为:"+sum); 4 Thread.sleep(2000);//这两秒代表对其他数据进行操作所耗费的时间 5 sum++; 6 System.out.println("线程:"+Thread.currentThread().getName()+"执行加1结束,sum的值为:"+sum); 7 } catch (InterruptedException e) { 8 // TODO Auto-generated catch block 9 e.printStackTrace(); 10 } 11 }
运行结果:
1 线程:Thread-0执行加1开始,sum当前值为:0 2 线程:Thread-0执行加1结束,sum的值为:1 3 线程:Thread-1执行加1开始,sum当前值为:1 4 线程:Thread-1执行加1结束,sum的值为:2
从以上数据可以看出,作用的对象是调用这个方法的对象,当第一个线程执行完此方法,第二个线程才开始执行。
在 ..\bin\com\test 目录下找到类对应的class文件,我的是Test.class, 使用 Javap -v Test.class 命令查看字节码信息,如下:
通过上面的截图可以看到,在add()方法上加了一个 ACC_SYNCHRONIZED 标识,JVM在解析的时候,根据这个标识实现方法同步。
2.2.2.静态方法
先看问题,将上面的代码改造为两个对象,代码如下:
1 package com.test; 2 3 public class Main { 4 public static int i = 0; 5 public static void add() { 6 try { 7 System.out.println("线程"+Thread.currentThread().getName()+"调用add()方法前,i的值为:"+i); 8 Thread.sleep(2000); 9 i=i+1; 10 System.out.println("线程"+Thread.currentThread().getName()+"调用add()方法后,i的值为:"+i); 11 }catch (InterruptedException e){ 12 e.printStackTrace(); 13 } 14 } 15 public static void main(String[] args) { 16 17 final Main min1 = new Main(); //对象1 18 final Main min2 = new Main(); //对象2 19 Thread ta = new Thread(new Runnable() { 20 public void run() { 21 min1.add(); 22 } 23 }); 24 Thread tb = new Thread(new Runnable() { 25 public void run() { 26 min2.add(); 27 } 28 }); 29 ta.start(); 30 tb.start(); 31 32 } 33 }
运行结果:
1 线程Thread-0调用add()方法前,i的值为:0 2 线程Thread-1调用add()方法前,i的值为:0 3 线程Thread-0调用add()方法后,i的值为:1 4 线程Thread-1调用add()方法后,i的值为:2
从运行结果可以看出,两个线程是交叉执行的,但是结果却是正确的,没有什么问题。但是,如果将线程增加到五个再看一下:
1 package com.test; 2 3 public class Main { 4 public static int i = 0; 5 public static void add() { 6 try { 7 System.out.println("线程"+Thread.currentThread().getName()+"调用add()方法前,i的值为:"+i); 8 Thread.sleep(2000); 9 i=i+1; 10 System.out.println("线程"+Thread.currentThread().getName()+"调用add()方法后,i的值为:"+i); 11 }catch (InterruptedException e){ 12 e.printStackTrace(); 13 } 14 } 15 public static void main(String[] args) { 16 17 final Main min1 = new Main(); //对象1 18 final Main min2 = new Main(); //对象2 19 final Main min3 = new Main(); //对象3 20 final Main min4 = new Main(); //对象4 21 final Main min5 = new Main(); //对象5 22 Thread ta = new Thread(new Runnable() { 23 public void run() { 24 min1.add(); 25 } 26 }); 27 Thread tb = new Thread(new Runnable() { 28 public void run() { 29 min2.add(); 30 } 31 }); 32 33 Thread tc = new Thread(new Runnable() { 34 public void run() { 35 min3.add(); 36 } 37 }); 38 Thread td = new Thread(new Runnable() { 39 public void run() { 40 min4.add(); 41 } 42 }); 43 Thread te = new Thread(new Runnable() { 44 public void run() { 45 min5.add(); 46 } 47 }); 48 tc.start(); 49 td.start(); 50 te.start(); 51 ta.start(); 52 tb.start(); 53 54 } 55 }
运行结果:
1 线程Thread-3调用add()方法前,i的值为:0 2 线程Thread-4调用add()方法前,i的值为:0 3 线程Thread-2调用add()方法前,i的值为:0 4 线程Thread-0调用add()方法前,i的值为:0 5 线程Thread-1调用add()方法前,i的值为:0 6 线程Thread-0调用add()方法后,i的值为:1 7 线程Thread-1调用add()方法后,i的值为:3 8 线程Thread-3调用add()方法后,i的值为:2 9 线程Thread-2调用add()方法后,i的值为:5 10 线程Thread-4调用add()方法后,i的值为:4
一眼就看出有问题了,原因不再讨论。再看给add()方法加锁后的情况:
1 public static synchronized void add() { 2 try { 3 System.out.println("线程"+Thread.currentThread().getName()+"调用add()方法前,i的值为:"+i); 4 Thread.sleep(2000); 5 i=i+1; 6 System.out.println("线程"+Thread.currentThread().getName()+"调用add()方法后,i的值为:"+i); 7 }catch (InterruptedException e){ 8 e.printStackTrace(); 9 } 10 }
运行结果:
1 线程Thread-2调用add()方法前,i的值为:0 2 线程Thread-2调用add()方法后,i的值为:1 3 线程Thread-1调用add()方法前,i的值为:1 4 线程Thread-1调用add()方法后,i的值为:2 5 线程Thread-0调用add()方法前,i的值为:2 6 线程Thread-0调用add()方法后,i的值为:3 7 线程Thread-4调用add()方法前,i的值为:3 8 线程Thread-4调用add()方法后,i的值为:4 9 线程Thread-3调用add()方法前,i的值为:4 10 线程Thread-3调用add()方法后,i的值为:5
这个结果就顺眼多了,再看字节码情况:
经过查看发现,这个加锁方式和普通方法加锁方式一样,都是加了一个标志。
2.2.3.同步方法块
将add()方法改造如下:
1 public void add(){ 2 synchronized (this) { 3 try { 4 System.out.println("线程:"+Thread.currentThread().getName()+"执行加1开始,sum当前值为:"+sum); 5 Thread.sleep(2000); //这两秒代表对其他数据进行操作所耗费的时间 6 sum++; 7 System.out.println("线程:"+Thread.currentThread().getName()+"执行加1结束,sum的值为:"+sum); 8 } catch (InterruptedException e) { 9 // TODO Auto-generated catch block 10 e.printStackTrace(); 11 } 12 13 } 14 }
运行结果:
1 线程:Thread-1执行加1开始,sum当前值为:0 2 线程:Thread-1执行加1结束,sum的值为:1 3 线程:Thread-0执行加1开始,sum当前值为:1 4 线程:Thread-0执行加1结束,sum的值为:2
以上运行结果正常,查看字节码信息:
为了查看信息量小,将同步代码块内的代码屏蔽掉,然后编译,查看字节码信息如下:
同步方法块和以上两种就不一样了,是通过 monitorenter 和 monitorexit 给对象 this 也就是Test类的对象加锁和解锁。
如果给同步代码块内添加任意可执行代码,情况就变了,比如加一句打印语句(不上图,自行想象),字节码信息如下:
居然出现两条monitorexit ,但是只有一个monitorenter,这就是锁的重入性,什么意思呢?对于同一个类的对象,线程在执行一个任务时,会获取一次锁,当执行完会释放锁,如果这个线程还要继续执行这个对象的其他任务,是不需要重新获取锁的,但执行完任务就要释放锁,顾名思义,锁的重入性。
综上,synchronized 加锁的方法就是使用ACC_SYNCHRONIZED 和 monitorenter—monitorexit 实现的,那么,接下来,就研究研究这三个词是什么。
2.2.4 synchronized 原理
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.11.10 中的解释是这样的:
同步方法在运行时常量池的method_info结构中通过ACC_SYNCHRONIZED标志区分,该标志由方法调用指令检查。当调用设置了ACC_SYNCHRONIZED的方法时,执行线程进入监视器,调用方法本身,并退出监视器,无论方法调用是正常还是突然完成。在执行线程拥有监视器期间,没有其他线程可以输入它。如果在调用synchronized方法期间抛出异常并且synchronized方法不处理异常,则在异步从同步方法中重新抛出之前,将自动退出该方法的监视器。
这里有个关键词:监视器。监视器是什么呢? 监视器又名monitor。每个对象都是一个监视器锁,当对象监视器锁被占用时,对象就是锁定状态,其他线程不能对其操作,当占用被解除时,其他线程就可以获取此对象。
那么上面三个词是怎么工作的呢?
a.monitorenter—monitorexit
monitorenter 和 monitorexit 是线程执行同步代码块时执行的指令,当线程执行同步代码块时会占用监视器,执行monitorexit 指令会解除监视器。
b.ACC_SYNCHRONIZED
当JVM调用方法时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
所以,两种方法本质没有区别。
要明白监视器怎么工作的,就得研究研究对象。
对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。对象头主要包括两部分(对象其他信息在此不做研究) markword 和 klass ,与锁有关的信息就存储子在markword中 ,如下图:
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
图片来自网络,如有雷同,纯属巧合。
从图片可以看出,对象头的后两位存储了锁的标志。初始状态是01,标识位加锁。偏向锁的标志位存储的是占用当前对象的线程的ID。
2.2.5 synchronized 优化
从上面的例子中,可以体会到,当有多个线程访问同步代码块时,如果每个线程执行几秒,那么将会很消耗时间,故而,需要对锁进行优化。
高效 并发是从JDK1.5到JDK1.6的一个重要改进,目前的优化技术有 适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁 等 这些技术是为了在线程之间更高效地共享数据,以解决竞争问题,从而提高程序的执行效率。
自旋锁与自适应锁:
如果物理机器上有一个以上的处理器,能让两个或两个以上的线程同时并行执行,就可以让后面请求锁的那个线程稍微等待一下,但不放弃处理器的执行时间,看看持有线程的锁是否很快就会释放锁,为了让线程等待,只需要让线程执行一个忙循环即自旋,这就是自旋锁。
自旋锁在JDK 1.4.2中引入,默认关闭,可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。默认次数可以通过参数-XX:PreBlockSpin来调整。
如果所被占用的时间很短,自旋等待的效果就会非常的好,反之,自旋的线程只会白白得消耗处理器资源,而不会做任何有用的工作,反而带来性能上的浪费,因此,自选锁等待的时间必须要有一定的限制,如果自旋锁超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式挂起锁了,默认次数是10。为了解决这个问题,引入自适应锁,JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
锁消除:
锁消除是Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
锁粗化:
如果一系列的连续操作都是对同一对象反复加锁和解锁,甚至加锁操作时出现在循环体中,那即是没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗,如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步范围扩展到整个操作序列的外部,就扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就好。
轻量级锁:
轻量级锁是JDK1,6中加入的新型锁机制,它是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
偏向锁:
在JVM1.6中引入了偏向锁,偏向锁主要解决无竞争下的锁性能问题,首先我们看下无竞争下锁存在什么问题:
现在几乎所有的锁都是可重入的,也即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。
2.2.6 内部锁条件的局限性:
(1)不能中断一个正在试图获得锁的线程
(2)试图获得锁时不能设定超时
(3)每个锁仅有单一的条件,可能是不够的。