并发编程带来的安全性挑战之同步锁

发布于:2023-01-04 ⋅ 阅读:(217) ⋅ 点赞:(0)

如果多个线程在做同一件事情,或者共享同一个资源变量时,就会产生线程安全星问题,此时就需要保证三要素:

  • 原子性:指的是一个或者多个操作,要么全部执行并且在执行的过程中不能被其他操作打断,要么全部不执行。也不存在上下文切换,线程切换会带来原子性问题。
  • 可见性:指的是多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。
  • 有序性:程序执行的顺序按照代码的先后顺寻执行,因为处理器看能会对指令进行重排序。jvm在编译java代码或者cpu执行jvm字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(前提是不改变程序结果)

原子性问题

用一个案例来说明原子性问题,在下面的案例中,我们创建了两个线程,都去调用incr()方法来对i这个变量进行叠加,每个线程都调用10000次,预期的结果是20000,但是实际结果却是小于20000的值

/**
 * 原子性问题
 */
public class StomicityDemo {
    int i = 0;

    public void incr() {
        i++;
    }

    public static void main(String[] args) {
        StomicityDemo demo = new StomicityDemo();
        Thread[] threads = new Thread[2];
        for (int j = 0; j < threads.length; j++) {
            // 创建两个线程
            threads[j] = new Thread(()->{
                // 每个线程跑10000次
                for (int i = 0; i < 10000; i++) {
                    demo.incr();
                }
            });
            threads[j].start();
        }
        try {
            threads[0].join();
            threads[1].join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(demo.i);
    }
}
复制代码

问题的原因

这个就是典型的线程安全问题中原子性问题的体现,就如原子性的定义说的,当一个线程在执行的过程中不被其他线程打断,我们得到的值应该就是20000,但是现在小于20000,说明其中一个线程在执行的过程,被其他线程打断了,导致结果不是预期值,具体是怎么被打断的呢?

在上面的代码中,i++是属于java高级语言中的编程指令,而这些指令最终可能会有多条cpu指令来组成,我们通过javap -v StomicityDemo.class查看字节码指令如下:

public incr()V
    L0
    LINENUMBER 13 L0
    ALOAD 0
    DUP
    GETFIELD zhl.thread.threadDemo1/StomicityDemo.i : I // 访问变量i
    ICONST_1 // 将整形常量1放入操作数栈
    IADD // 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈
    PUTFIELD zhl.thread.threadDemo1/StomicityDemo : I // 访问类字段(类变量),复制给Demo.i这个变量
复制代码

这三个操作,如果满足原子性,那么就需要保证某个线程在执行这个指令时,不允许其他线程干扰,然而实际上却存在这个问题

图解问题本质

前面我们说过,一个cpu核心在同一时刻只能执行一个线程,如果线程数量远远大于cpu的核心数,就会发生线程的切换,这个切换动作可以发生在任何一条cpu指令执行完之前。

对于i++这三个cpu指令来说,如果线程A先拿到执行权限,执行指令GETFIELD将i=0加载到寄存器中后,做了线程切换,假设切换到了线程B,此时线程B同样执行指令GETFIELD将i=0加载到寄存器中,然后线程B按下图顺序执行剩余指令,执行完后再切换到线程A中,此时线程A接着之前未执行完的指令继续往下执行,就会导致最终的结果时1,而不是2.

这就是在多线程环境下,存在原子性问题,那么怎么解决呢?

认真观察上面这个图,表面上是多线程对于同一个变量的操作,实际上是 i++ 这行代码,它不是原子的,所以才导致多线程环境下出现这个问题。

也就是说我们只要保存,i++这个指令在运行期间,在同一时刻只能由一个线程来访问,就可以解决这个问题。可以加Synchronized这个关键字来解决。

Synchronized的基本应用

Synchronized由三种方式来实现加锁,不同的修饰类型,代表锁的控制粒度。

  1. 修饰实力方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。

    /**
     * Synchronized修饰实例方法
     */
    public class SynchronizedDemo {
        // 共享资源
        static int num = 0;
    ​
        // 修饰实力方法
        public synchronized void incr() {
            num++;
        }
    ​
        public static void main(String[] args) throws InterruptedException {
            SynchronizedDemo demo1 = new SynchronizedDemo();
            SynchronizedDemo demo2 = new SynchronizedDemo();
    ​
            Thread t1 = new Thread(()->{
                for (int i = 0; i < 10000; i++) {
                    demo1.incr();
                }
            });
    ​
            Thread t2 = new Thread(()->{
                for (int i = 0; i < 10000; i++) {
                    demo2.incr();
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(num);
        }
    }
    复制代码

    以上代码num的输出小于20000,虽然使用了synchronized修改了incr()方法,但是是通过不同的实例demo1和demo2来调用的incr()方法,这就意味着存在两个不同实例的对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2使用的是不同的对象锁,因此线程安全是无法保证的。

    ​
    /**
     * Synchronized修饰实例方法
     */
    public class SynchronizedDemo1 {
        // 共享资源
        static int num = 0;
    ​
        // 修饰实例方法
        public synchronized void incr() {
            num++;
        }
    ​
        public static void main(String[] args) throws InterruptedException {
            SynchronizedDemo1 demo = new SynchronizedDemo1();
            Thread t1 = new Thread(()->{
                for (int i = 0; i < 10000; i++) {
                    demo.incr();
                }
            });
    ​
            Thread t2 = new Thread(()->{
                for (int i = 0; i < 10000; i++) {
                    demo.incr();
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(num);
        }
    }
    ​
    复制代码

    以上代码num的输出结果为20000,synchronized修饰了incr方法,线程t1和t2在执行时,是通过同一个实例对象demo来调用的incr方法,两个线程调用的是同一实例锁,其中一个线程拿到锁后另一个线程无法进入到实例方法,保证了线程的安全性问题。

    1. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。

      **
       * Synchronized修饰实例方法
       */
      public class SynchronizedDemo2 {
          // 共享资源
          static int num = 0;
      
          // 作用于静态方法
          public static synchronized  void incr() {
              num++;
          }
      
          public static void main(String[] args) throws InterruptedException {
              Thread t1 = new Thread(()->{
                  for (int i = 0; i < 10000; i++) {
                      incr();
                  }
              });
      
              Thread t2 = new Thread(()->{
                  for (int i = 0; i < 10000; i++) {
                      incr();
                  }
              });
              t1.start();
              t2.start();
              t1.join();
              t2.join();
              System.out.println(num);
          }
      }
      复制代码

      当synchronized作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态成员的并发操作。

      1. 修饰代码块,指定加锁对象,对给定对象锁,进入同步代码库前要获得给定的对象锁。
/**
 * Synchronized修饰代码块
 */
public class SynchronizedDemo1 {
    // 共享资源
    static int num = 0;

    // 修饰代码块
    public void incr() {
        // this,当前实例对象锁
//        synchronized (this){
//            num++;
//        }
        // class对象锁
        synchronized (SynchronizedDemo1.class){
            num++;
        }

    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedDemo1 demo1 = new SynchronizedDemo1();
        SynchronizedDemo1 demo2 = new SynchronizedDemo1();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                demo1.incr();
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                demo2.incr();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(num);
    }
}
复制代码

如果传入的是this,表示修饰的是当前实例对象锁,如果两个不同的对象demo1和demo2调用incr方法,存在线程安全问题

如果我们传入的是SynchronizedDemo1.class,表示class对象锁,此时两个不同的对象demo1和demo2调用incr方法,如果一个线程拿到锁,另一个线程就需要等待,不存在线程安全问题。

当前我们也可以传入其他,比如new一个新的对象等等。

// 实例锁
Object object = new Object();
synchronized (object){
    num++;
}
// class锁
synchronized(Lock.class){
    num++;
}
复制代码

总之,是否能保证线程的原子性,主要取决于synchronized的作用范围。

锁的实现模型理解

synchronized到底帮我们做了什么,为什么能解决原子性。

在没有加锁之前,多个线程去调用incr()方法时,没有任何限制,都是可以同时拿到这个i值进行++操作的,但是当加了synchronized锁之后,线程A和B就由并行执行变成了串行执行。

synchronized的原理

synchronized时如何实现锁的,以及锁的信息存储在哪里?就拿上图来说,如果线程A拿到锁,线程B怎么知道当前锁被占了,这个地方一定会有一个标记来实现,而这个标记一定存储在某个地方。

synchronized加锁在对象上,锁标记就在这个对象的对象头(markword)中。

Markword对象头

在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对其填充(Padding)

  1. 对象头:java对象头一般占用两个机器码(在32位虚拟机中,1个机器码等于4个字节,也就是32bit,在64位虚拟机中,1个机器码时8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为jvm虚拟机可以通过java对象的元数据信息确定java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组的长度。
  2. 实例数据:存放类的属性数据信息,包括父类的属性信息。大小由各个成员变量来决定,比如:byte占1个字节8个bit,int占4个字节32bit。
  3. 对其填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了自己对齐。

通过ClassLayout打印对象头

为了更加直观的看到对象的存储和实现,我们可以使用jol查看对象的内存布局。

  • 添加jol依赖

    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.9</version>
    </dependency>
    复制代码
  • 编写单元测试,在不加锁的情况下,对象头信息打印

    public class LayoutDemo {
        Object o = new Thread();
    ​
        public static void main(String[] args) {
            LayoutDemo layoutDemo = new LayoutDemo();
            System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
        }
    }
    复制代码
  • 输入内容如下

zhl.thread.threadDemo1.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
​
复制代码

关于synchronized锁的升级

在jdk1.5之前一直是一种重量级锁的状态,也就是每次加锁和释放锁都直接和操作系统打交道。

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级,需要的注意的是,锁的升级是不可逆的。

这么设计的目的,其实是为了减少重量级锁带来的性能开销,尽可能的在无锁状态下解决线程并发问题,其中偏向锁和轻量级锁的底层实现是基于自旋锁,它相对于重量级锁来说,算是一种无所的实现。

  • 默认情况下偏向锁是开启状态,偏向的线程ID是0,偏向一个Anonymous BiasedLock
  • 如果有线程去抢占锁,那么这个时候线程会先去抢占偏向锁,也就是把markword的线程ID改为当前抢占锁的线程ID的过程,此时markword的结构也就变为偏向锁结构,这个线程再次请求锁时,无需再做任何同步操作,即获取锁过程。
  • 如果有线程竞争,这个时候会撤销偏向锁,升级到轻量级锁,线程在自己的线程栈中会创建一个LockRecord,用CAS操作把markword设置为指向自己这个线程的LR的指针,设置成功后表示抢占到锁。
  • 如果竞争加剧,比如有线程超过10次自旋( -XX:PreBlockSpin参数配置 ),或者自旋线程数超过cpu核心数的一半, 在1.6之后,加入了自适应自旋Adapative Self Spinning. JVM会根据上次竞争 的情况来自动控制自旋的时间。 升级到重量级锁,向操作系统申请资源, Linux Mutex,然后线程被挂起进入到等待队列。

轻量级锁的获取及原理

接下来,我们通过下面的例子来演示一下,通过加锁之后继续打印对象布局信息,来关注对象头里面的变化。

public class LayoutDemo {
    Object o = new Thread();
​
    public static void main(String[] args) {
        LayoutDemo layoutDemo = new LayoutDemo();
        System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
        synchronized (layoutDemo){
            System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
        }
    }
}
复制代码

得到的对象布局信息如下,对照上面markword状态变化图

// 在未加锁之前,对象头中的第一个字节最后三位为 [001], 其中最后两位 [01]表示无锁,第一位[0]也表示无锁
zhl.thread.threadDemo1.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
// 下面部分是加锁之后的对象布局变化
// 其中在前4个字节中,第一个字节最后三位都是[000], 后两位00表示轻量级锁,第一位为[0],表示当前不是偏向锁状态。
zhl.thread.threadDemo1.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           c8 f2 7f 56 (11001000 11110010 01111111 01010110) (1451225800)
      4     4                    (object header)                           0c 00 00 00 (00001100 00000000 00000000 00000000) (12)
      8     4                    (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码

按上面说的,锁的升级是基于线程竞争情况,来实现从偏向锁到轻量级锁再到重量级锁的升级,但是此处明明没有线程的竞争,为什么是轻量级锁呢?

偏向锁的获取及原理

默认情况下,偏向锁的开启是有延迟的,默认是4秒。这么设计的目的是因为jvm虚拟机自己有一些默认的启动线程,这些线程里面有很多的synchronized代码,这些synchronized代码启动的时候就会出发竞争,如果使用偏向锁,就会造成偏向锁不断进行锁升级和撤销,效率低下。

通过这个jvm参数可以将延迟设置为0

-XX:BiasedLockingStartupDelay=0

再次运行下面代码

public class LayoutDemo {
    Object o = new Thread();
​
    public static void main(String[] args) {
        LayoutDemo layoutDemo = new LayoutDemo();
        System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
        synchronized (layoutDemo){
            System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
        }
    }
}
复制代码

得到如下的对象布局,可以看到对象头中的高位第一个字节最后三位数为【101】,表示当前为偏向锁。

这里的第一个对象和第二个对象的锁状态都是101,是因为偏向锁打开状态下,默认会有配置匿名的对象获取偏向锁

zhl.thread.threadDemo1.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000101 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
​
zhl.thread.threadDemo1.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           c8 f2 7f 56 (00000101 00110000 01001010 00000011) (1451225800)
      4     4                    (object header)                           0c 00 00 00 (00001100 00000000 00000000 00000000) (12)
      8     4                    (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码

重量级锁的获取

在竞争激烈的情况下,线程一直无法获得锁得情况下,就会升级到重量级锁。

通过两个线程来模拟竞争得场景

public class LayoutDemo {
    public static void main(String[] args) {
        LayoutDemo layoutDemo = new LayoutDemo();
        Thread t1 = new Thread(()->{
            synchronized (layoutDemo){
                System.out.println("t1 lock ing");
                System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
            }
        });
        t1.start();
        synchronized (layoutDemo){
            System.out.println("main lock ing");
            System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
        }
    }
}
复制代码

从结果可以看出,在竞争的情况下锁的标记为 [010] ,其中所标记 [10]表示重量级锁

main lock ing
zhl.thread.threadDemo1.LayoutDemo object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           7a c6 a0 a1 (01111010 11000110 10100000 10100001) (-1583298950)
      4     4        (object header)                           fd 01 00 00 (11111101 00000001 00000000 00000000) (509)
      8     4        (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
​
t1 lock ing
zhl.thread.threadDemo1.LayoutDemo object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           7a c6 a0 a1 (01111010 11000110 10100000 10100001) (-1583298950)
      4     4        (object header)                           fd 01 00 00 (11111101 00000001 00000000 00000000) (509)
      8     4        (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码

CAS

CAS这个在synchronized底层用的非常多,他的全称有两种

  • Compare and swap
  • Compare and exchange

就是比较并交换的意思。它可以保证在多线程环境下对于一个变量修改的原子性。

CAS的原理很简单,包含三个值当前内存值(V)、预期原来的值(E)以及期待更新的值(N)。


网站公告

今日签到

点亮在社区的每一天
去签到