2. 深入理解Synchronized

首先看这样一段代码

  static int count = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i =0;i<5000;i++){
                    count++;
                }
            }
        },"t1");

        Thread t2 =  new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i =0;i<5000;i++){
                    count--;
                }
            }
        },"t2");

        t1.start();
        t2.start();
        try {
            t1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("最终count的值 = {}",count);
    }

对于共享变量count,在一个线程中循环5000次自加,在另一个线程中循环5000次自减,等两个线程都运行结束之后,打印出count的值并不等于0,
这是因为对于count++来说,在字节码中的指令时这样的

       0: getstatic     #2                  // Field count:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field count:I

这里分为了四步操作

  1. 取出静态变量count
  2. 取出数字1
  3. 执行两者相加
  4. 放入到静态变量中

同理count--也是同样的操作

另外JMM也定义了线程对变量的操作并不是直接在主内存读写,而是读取到线程的工作内存中,等工作内存操作完毕之后再写入到主内存中来,这样就会出现当一个线程操作完自加之后还没有写入到主内存中时,发现了线程上下文切换,另一个线程读取主内存时 还是初始值,这时候完成了自减并写入主内存中,发生上下文切换时,第一个线程开始往里面写入之前的值,就会造成数据的覆盖,这也是为什么最终的数值并没有如逾期一样

临界区

  • 一个程序运行多个线程是没有问题的
  • 问题出在多个线程访问共享资源
    1. 多个线程读共享资源也没有问题
    2. 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写问题,这个代码就叫做临界区
    如上面实例中 count++,count-- 就是临界区

竞态条件(Race Condition)

  • 在多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生的竞态条件
  • 为了避免临界区的竞态条件发生,可以使用如下解决方法
    1. 阻塞式:synchronized,Lock
    2. 非阻塞式:原子变量

Synchronized

synchronized 就是我们常说的对象锁,它是采用互斥的方式让同一时刻最多只有一个线程持有对象说,其他线程再想获取这个对象锁时就会阻塞住,这样保证了拥有锁的线程可以安全的执行临界区代码,不用担心线程的上下文切换

值得注意的是在java中互斥和同步都可以采用synchronized关键字

  • 互斥是保证临界区的竞态条件发生时,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后,顺序不同,需要一个线程等待其他线程运行到某个点
  • synchronized 实际上就是用对象锁保证了临界区代码的原子性

synchronized语法

    synchronized(对象){
        临街区
    }

在方法上的synchronized是这样的

public class Test {
    public synchronized void test(){
    }
}

等价于

public class Test {
    public void test(){
        synchronized (this){
        }
    }
}

这两个方法表示的是针对成员方法而言锁住的对象实例

public class Test {
    public static synchronized void test() {
    }
}

等价于

public class Test {
    public void test(){
        synchronized (Test.class){
        }
    }
}

对于静态方法而言,锁住的是类对象

变量的线程安全

成员变量和静态变量
  • 如果没有被共享,是线程安全
  • 如果被共享了
    1. 如果只有读操作,是线程安全
    2. 如果有读写操作,需要考虑线程安全的问题
局部变量
局部变量自身是安全的
public class Test {
    public void test(){
        int i= 10;
        i++;
    }
}

这样一个代码,查看字节码是这样的

  public void test();
    Code:
       0: bipush        10
       2: istore_1
       3: iinc          1, 1
       6: return

可以看出它的i++的指令是iinc,这是因为局部变量属于线程内部的,当多个线程访问时,会在每个线程的栈帧内存中被创建多份,因此不存在共享,是线程安全的

局部变量的引用

在验证局部变量之前先看一下成员变量的情况

class UnSafeThread{
    ArrayList list = new ArrayList();
    public void method1(Thread thread,int loopNum){
        for(int i = 0;i

这个类有一个成员变量list,当多个线程调用method1时,对于method2和method3的调用都涉及到对成员变量的读写,这就有竞态条件的发生,因此是线程不安全的
接下来看局部变量引用

  1. 未对外暴露局部变量引用
class SafeThread{
    public void method1(Thread thread,int loopNum){
        ArrayList list = new ArrayList();
        for(int i = 0;i

这个类局部变量list相当于在每个线程的栈帧中都有副本,因此多个线程访问时,结果都是可预期的,因此是线程安全的
2.对外暴露局部变量引用

class SafeThread{
    public void method1(Thread thread,int loopNum){
        ArrayList list = new ArrayList();
        for(int i = 0;i

还是原来的代码,只不过把method2和method3的修饰符修改为public,这样一来,子类就可以重写这两个方法 如下

class ChildUnSafeThread extends SafeThread{
    @Override
    public void method3(ArrayList list) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug("size = {}",list.size());
                list.remove(0);
            }
        }).start();
    }
}

这样在子类的方法中新建一个线程,同样也做到多线程都访问共享资源,也会造成竞态条件的发生,那么这个类就不是线程安全的类

通过上面的例子也可以看出访问修饰符 private,final 在一定程度上是可以保证线程的安全的

常见的线程安全的类

所谓线程安全就是指多个线程调用类的同一个实例的某个方法是安全的,这里需要注意的两点

  1. 它们的每个方法都是原子的
  2. 但是它们的多个方法的组合不一定是原子的

这些常见的线程安全的类有:

  • String
  • Integer等包装类
  • StringBuffer
  • Random
  • Vector
  • HashTable
  • java.util.concurrent下的类

对于String,Integer这些不可变类肯定是线程安全的类,因为它们实例的属性是没有修改过的,虽然String的subString和replace看上去改变了值,但是实际上它们内部是又创建了新的对象
对于线程安全的类一般都是使用synchronized来加锁,正如上面所说,虽然这些线程安全的类的每个方法都是原子的但是不代表多个方法组合起来还是线程安全的

比如对于HashTable来说,它的put和get方法都是线程安全的 但是如果结合起来使用就未必是线程安全的 比如下面的代码

 Hashtable table = new Hashtable<>();
    public void putIfNull(String key,String value){
        if(table.get(key) ==null){
            table.put(key, value);
        }
    }

上面的代码在多线程访问的情况下就是不安全的,当两个线程t1和t2同时执行这段代码时 有可能t1执行到get方法时发生了上下文切换,这时候t2执行get方法,然后执行put方法,再次发生上下文切换,再执行线程t1的put方法这样就把线程2的put的值给覆盖掉了,产生了不可预期的结果,因此是线程不安全的,如果想要线程安全,需要在putIfNull上面再添加一个synchronized 关键字

对象头

32JVM对象头结构
  1. 普通对象头结构

普通对象的头结构是64bits也就是8个字节,分别是四个字节的MarkWord和四个字节的Klass Word
如下图所示

Mark word(32 bits) Klass Word(32 bits)
  1. 数组对象头结构

对象数组与普通对象头结构的区别是在后面多了个四个字节的数组长度,因此是12个字节

Mark word(32 bits) Klass Word(32 bits) array length(32bits)

对于基本数据类型int 来说,它占用的是四个字节,但是如果用到包装类Integer的话,它不仅需要内容的四个字节 还需要对象头的八个字节,其内存占用是基本数据类型的3倍,这也是为什么能用基本类型就不用其包装类的原因

  1. Mark Word 结构

Mark Word 是用来存储一个对象的状态信息的结构,通常根据它的后两位可以分为五种状态

  • 正常状态(01)
  • 偏向锁(01)
  • 轻量级锁(00)
  • 重量级锁(10)
  • GC(11)

a. 首先看正常状态的MarkWord的结构

identity_hashCode(25 bits) age(4 bits) biased_lock(1 bits) flag(2 bits)

其中flag在正常状态下就是01

biased_locked: 是否是偏向锁标记,正常状态是 0,如果是偏向锁为1,这也是为什么正常状态和偏向锁状态在最后两位都是01的原因

age:表示的是分带年龄,因为是四个字节,可以看出一个对象的最大的年龄是15

identity_hashCode:对象标识Hash码

b. 偏向锁状态结构

thread(23 bit) epoch(2 bits) age(4 bits) biased_lock(1 bits) flag(2 bits)

偏向锁的结构跟正常状态的差不多,flag跟正常状态是一样的,都是01

biased_lock在变相说状态下是1,

thread:持有偏向锁的线程ID

epoch: 偏向时间戳

c. 轻量级锁状态结构

ptr_to_lock_record(30 bits) flag(2 bits)

ptr_to_lock_record: 指向栈中锁记录的指针

flag:此时为00

d. 重量级锁状态结构

ptr_to_heavyweight_monitor(30 bits) flag(2 bits)

ptr_to_heavyweight_monitor: 指向管程Monitor的指针
flag: 此时为10

e. GC状态结构

flag:此时为11

  1. klass Word
    这一部分用于存储对象的类型指针,JVM通过这个指针确定对象是哪个类的实例
64位JVM对象头

通过对比32位对象头更好的理解64位对象头

  • 普通对象的 MarkWord和klassWord分别都是64位
  • 数组对象的 MardWord和KlassWord,arrayLength分别都是64位
  • 对于MardWord来说
    1. 正常状态下结构是这样的
unused(25bits) identity_hashcode(32bits) unused(1bits) age(4bits) biased_lock(1bits) flag(2bits)
  1. 偏向锁状态下的结构
thread(54) epoch(2bits) unused(1bits) age(4bits) biased_lock(1bits) flag(2bits)
  1. 对于轻量级锁和重量级锁指向栈中锁记录或者指向Monitor都是62位
  • KlassWord 也是64位,如果应用的对象过多,使用64位的指针会浪费大量的内存,为了节约内存,可以使用选项+UseCompressedOops开启指针压缩,开启指针压缩后下列指针会被压缩至32位
    1. 每个Class的属性指针(即静态变量)
    2. 每个对象的属性指针(即对象变量)
    3. 普通对象数组的每个元素指针
  • 如果对象是数组,那么对象头还需要额外的空间用于存储数组的长度,使用+UseCompressedOops同样会压缩该区域从64位至32位

Monitor

synchronized锁住的是对象,当使用synchronized锁住对象obj时,实际上是该对象obj与一个Monitor对象产生了联系
一个Monitor包括三部分:

  • Owner:表示是哪个线程持有锁
  • entrySet:但有多个线程访问锁时,如果发现Owner另有其人,该线程就会进入EntrySet
  • waitSet:wait的时候线程会进入此

实际上当第一个线程执行到synchronized语句时,首先去判断obj对应的Monitor的Owner是否为空,若为空,则将自己线程ID赋值给Owner,表示自己占有了锁,若Owner不为空,表示已经有别的线程占有了锁,则此线程就会进入entrtset队列中,等待占有Owner的线程释放锁,也就是Owner会为空,这时候entrySet的线程会有调度器调度,选出一个线程作为Owner

针对Obj与Monitor的关联,可以通过字节码来查看,首先这样一个简单的语句

   static Object obj = new Object();
    public static void main(String[] args) {
        synchronized (obj){
        }
    }

通过javap 反编译之后可以看出

  public static void main(java.lang.String[]);
    Code:
       0: getstatic    
       3: dup
       4: astore_1
       5: monitorenter
       6: aload_1
       7: monitorexit
       8: goto          16
      11: astore_2
      12: aload_1
      13: monitorexit
      14: aload_2
      15: athrow
      16: return
    Exception table:
       from    to  target type
           6     8    11   any
          11    14    11   any

逐一解释一下这些命令行

  1. 首先取到静态变量也就是Obj对象
  2. 复制一份
  3. 把复制的Obj对象存起来
  4. 进入Monitor
  5. 把第四步存起来的Obj备份取出来
  6. 出Monitor
  7. 跳到16语句也就是return
    11到14的语句的意思是如果在临界区发生异常,这时候在异常里面同样取到之前存起来的对象用于解锁,这样可以做到即使发生异常,也会有渠道释放锁

轻量级锁与锁膨胀

对于刚才Monitor的理论其实是在竞态条件发生时使用synchronized的一种现象,如果不存在同时多个线程同时调用synchronized代码块,则首先会使用轻量级锁

可以使用org.openjdk.jol.info.ClassLayout来打印对象的头信息,其实我们最重要的是要看MarkWord的信息,当然对于自己JVM是32位和64位也必须清楚,因为不同的JVM的位数是不同的可以通过System.getProperty("sun.arch.data.model")来查看

首先对于出现的MarkWord给予必要的解释

01 00 00 00 (00000001 00000000 00000000 00000000) (1)              00 00 00 00 (00000000 00000000 00000000 00000000) (0)

这是64位JVM上得出的Object的MarkWord,首先明确的是这是一个小端存储,也就是说我们分析是的数据是

00 00 00 00 00 00 00 01

根据上面对象头MardWord 64位的结构分析,最后三位是001也就是说这是一个没有偏向锁的正常状态的对象头,虽然它的前面都是0,但是也需要再次的说明的下,它的前25位表示没有用,紧跟着32表示HashCode,然后又1位表示没有用到,后面四位就是年龄

对于当只有一个线程访问synchronized的时候是否是轻量级锁我们使用下面这个代码来验证

public class Monitor {
    static Object obj = new Object();
    public static void main(String[] args) {
        synchronized (obj){
            log.debug(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}

这个代码的意思就是在锁住Obj的时候打印出obj的对象头信息,看看是否符合轻量级锁,打印的结果是

 f8 48 bb 0b (11111000 01001000 10111011 00001011) 
 00 70 00 00 (00000000 01110000 00000000 00000000) 

可以看出f8对应的最后两位是00,根据对象头的结构分析,这个就是轻量级锁,前面的62位指向的是线程中栈帧的锁记录地址

锁记录(Lock Record)

锁记录对象粗略的包含两个东西

  • 锁记录的值
  • 指向对象的指针
加锁过程

当一个线程执行synchronized(obj){}的时候,这时候会检查obj对象头的MardWord是否是正常状态(01),如果是正常状态会在栈帧中创建一个锁记录对象,其中指向对象的指针指向了obj,并会把锁记录对象的地址与obj的MarkWord通过CAS进行交换

  1. 如果CAS交换成功,锁记录的值存储的是obj的MarkWord,对象头中存储的是锁记录对象的地址和状态00,这个也就是我们在对象头中的轻量级锁的结构显示的,后两位是00表示轻量级锁,前面的62位表示的锁记录对象地址
  2. 如果CAS交换失败,这里面分为两种情况
    a. 锁重入:如果说执行了如下代码
public class Monitor {
    static Object obj = new Object();
    public static void main(String[] args) {
        synchronized (obj){
         method1();
        }
    }
    public static void method1(){
        synchronized (obj){

        }
    }
}

当线程的栈帧的锁记录对象再一次与obj进行CAS交换时,这时候obj的MarkWord存储的是同一个线程的上一个锁记录的地址,因此当前的锁记录的值设置为null,并且指向对象的指针同样指向obj,这就是锁冲入,这时候锁记录充当的是重入次数的计数器
b. 锁膨胀:当线程t1的锁记录欲与obj对象MarkWord进行CAS交换时,发现obj的MarkWord的后两位已经变为00了,这表示已经有别的线程持有了obj的轻量级锁,这个时候它就申请了Monitor锁,也就是重量级锁,它把Obj的MarkWord置为指向Monitor的地址,后两位置为10,然后自己进入到entrySet进行等待(此时的Monitor的Owner应该是那个持有轻量级锁的线程)

解锁

持有轻量级锁的对象执行完synchronized的代码时,这时候需要解锁,这时候相当于把锁记录的值与obj的MarkWord进行CAS交换,如果成功表示解锁成功,如果失败也是对标加锁的流程有两种情况

  1. 如果是锁重入,这时候锁记录的值为null,直接去掉锁记录即可
  2. 如果发现obj的MarkWord的后两位变成了10,表示有别的线程触发了锁膨胀,这时候通过MarkWord的重量级锁地址找到Monitor,然后置Owner为空,然后通知entrySet的线程,这样相当于触发了重量级锁的解锁过程

自旋优化

  • 线程阻塞会涉及到上下文的切换,这会影响到性能,因为对于重量级锁可以通过自旋进行优化
  • 当线程t1申请Monitor时,发现已经有别的线程持有了该重量级锁,t1并不是立即进入到阻塞队列中,而是通过自旋重试,当在重试一定次数内,如果别的线程释放了锁,则t1就申请到了锁,如果别的线程没有释放锁,则表示自旋失败,进入阻塞
  • 这种自旋在一定程度上对锁进行了优化,但是这要求的基础必须是多核CPU,对于单核CPU没有意思
  • 自旋优化从1.7以后是默认的,是JVM层面的优化

偏向锁

  • 对于大部分情况只有一个线程执行synchronized代码的情况,如果对于锁重入的情况,需要多次进行CAS,这其实也是会影响性能,因此从1.6之后,引入了偏向锁的优化,也就是说如果一个线程执行到了synchronized(obj)的情况,它会把线程id设置到obj的MarkWord上,这也是我们在上面对象头中分析的那样,这些下一次该线程发现是本线程id,就不会再进行CAS了,这样的优化对于特种情况的性能有提升
  • 偏向锁的后两位与对象的正常状态一样都是01,但是倒数第三位是1,一个对象默认是开启偏向锁的,只不过是在启动的时候会延迟,因此为了验证偏向锁可以在对象创建前Sleep几秒,这样就能很清晰的看到了
  • 也可以通过-XX:BiasedLockingStartupDelay=0 关闭偏向锁延迟,这样创建出来的对象都具有偏向锁特征
  • 可以通过-XX:-UseBiasedLocking关闭偏向锁,不管在延迟之前创建的对象还是关闭偏向锁创建的对象,都是正常状态的,当执行synchronized的时候,获取的是轻量级锁
  • 如果创建了对象,调用了对象的hashCode方法,这个时候会消除偏向锁,这是因为对于对于偏向锁来说没有多余的空间存储hashCode
  • 具有偏向锁特征的对象刚创建出来的时候MarkWord的后三位是101,前面都是0,当执行完synchroized的时候这时候前面的0会被补上线程id,执行完之后释放锁,这个线程Id也还是在的,这就是偏向锁的特性,下次线程访问的时候不再进行CAS,但是不管在什么时候一旦调用了obj.hashCode()方法,这个偏向锁就会立刻被消除了,变成了正常状态的对象头,MarkWord的后三位也就变成了001

偏向锁撤销,批量重偏向和批量撤销

  • 对于偏向锁,如果有两个线程不同时段的访问synchronized(obj)的时候,这个时候偏向锁会升级到轻量级锁也就是00,这种情况叫做偏向锁撤销
  • 但是如果两个线程同时访问的synchronized(obj)的话,这时候就有了竞争,这时候偏向锁就升级为重量级锁
  • 对于偏向锁撤销最形象的例子就是,首先t1先访问synchronized(obj),这时候obj偏向的是线程t1,当t1执行完之后,注意这个一定是执行完,否则就是重量级锁了,当t1执行完之后,t2执行synchronized(obj),这个时候obj的状态就由偏向锁升级为轻量级锁了
  • 如果一个类的多个对象偏向了线程t1,这时候另一个线程t2在线程t1执行完毕之后(此处一定要注意两个线程是错开时间执行的)访问了该类的对象的锁,这时候就会出现偏向锁取消,当取消到一定的阈值(20)的时候,这时候JVM会觉得偏向线程t1可能是错的,这时候剩下对象就会批量的偏向于t2,这个就是批量重偏向,如果要验证这个问题,最好注意几点
    1. 两个线程一定要错开时间
    2. 某个类比如Object的对象已经要多于20(比如创建40个对象),最好用ArrayList管理
    3. 可以看到从20到39的对象在线程t2中并不是被偏向锁取消了,而是批量的都重偏向t2了
    4. 重新创建的对象并不受影响,还是偏向于访问它的线程
  • 如果一个类的多个对象的偏向锁被取消多次,超过阈值(40),这时候JVM就会觉得竞争激烈不应该有偏向锁,这个时候就会取消批量撤销偏向锁,就连新创建的对象也不具备偏向锁,直接是正常状态的,也就说后三位是001,验证这个问题的代码需要注意几点
    1. 需要三个线程t1,t2,t3
    2. 需要的对象个数多于20个,这个的基本情况就是在t1线程中各个对象都偏向于t1,在t2线程中前面20个相当于给取消了,后面的都偏向于t2线程,这个过程就是批量重偏向,在t3线程中相当于又给20个偏向取消,这时候达到了批量撤销的阈值,这时候后面的全部都给撤销了偏向锁,重新创建的对象也是正常对象,不具有偏向锁特征

锁消除优化

  • 对于执行synchronized的代码块不管是轻量级锁,重量级锁还是偏向锁,都会对性能有一定的影响,但是下面的代码
  static int x =0;
    public void methed1(){
        x++;
    }
    public void method2(){
        Object obj = new Object();
        synchronized (obj){
            x++;
        }
    }

这两个方法的一个有锁,一个没有锁,但是两个方法的执行时间所差无几,这是因为代码运行的时候有个JIT会对字节码的热点代码进行及时编译,它会再一次的进行优化,因为在method2的锁的对象是局部变量,JIT就认为这不会有竞争,就会在优化的时候进行锁消除,因此看上去是和无锁的状态是一样的

  • 这种优化默认是开启的,可以通过-XX:-EliminateLocks 关闭锁消除,那么再去看上面的代码就会发现性能上相差不少

并发度与活跃性

多把锁

在上面的理论与示例中都是共享的同一把锁,这样并发度就降低了,就像是租房子,一个三室一厅的房子一次只租给一个人,可能实际上那个人的需求只是一个房间,因此可以把套房分开各个房间单独租出去,这样各个房间是单独的锁,有效的利用了资源避免了浪费
如下代码

    private Object studyRoom = new Object();
    private Object sleepRoom = new Object();
    public void study(){
        synchronized (studyRoom){
            log.debug("学习");
            try {
                Thread.sleep(1000);
                log.debug("学习完了");

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void sleep(){
        synchronized (sleepRoom){
            log.debug("睡一会");
            try {
                Thread.sleep(1000);
                log.debug("睡醒了");

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
死锁

如果如上的情况有多把锁,就有可能出现死锁问题,比如有两个线程t1持有锁A,准备请求锁B
t2线程持有锁B,准备请求锁A,那么t1线程一直等待t2线程释放锁B,t2线程一直等待t1线程释放锁A,那么就会出现两个线程一直无限期的等待下去,这就造成了死锁

活锁

如果两个线程互相改变对方的结束条件那么也会造成两个线程结束不了,一直运行下去,这种情况就是活锁,如下代码所示:

 static int count =10;
    public static void main(String[] args) {

        new Thread(new Runnable() {
            @Override
            public void run() {

                while(count>=0){
                    count--;
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    log.debug("count = {}",count);
                }

            }
        },"t1").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                while(count<=20){
                    count++;
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    log.debug("count = {}",count);
                }
            }
        },"t2").start();
    }
饥饿线程

表示在多个线程中,因为加锁的逻辑问题导致的某些线程经常或者大部分情况不被调度,那么这个线程就是饥饿线程

你可能感兴趣的:(2. 深入理解Synchronized)