线程安全(一)-简单使用锁


什么是线程安全

想象一下这样一个场景:你去酒店开了一间房,可是房间没有上锁,任何从你房间路过的人都可以推门而入(如果他们想的话)。在这样的房间里睡觉 安全吗?显然是不安全的。路不拾遗、夜不闭户这种理想状态只会出现在理想中。

同样的,在多线程的情况下,也存在线程安全的问题。我们先理解下下面的代码:

 private static int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    //每个线程让count自增100次
                    for (int i = 0; i < 100; i++) {
                        count++;
                    }
                }
            }).start();
        }

        try{
            Thread.sleep(2000);
        }catch (Exception e){
            e.printStackTrace();
        }
        System.out.println(count);
    }

counut 的输出值是否为 200?答案是否定的。因为 count++ 这个操作不是原子性的

  • 先要从主存获取count值到,然后+1,最后再把结果写到主存。

因为不是原子性的,就有可能被会其他线程打断,结果不确定性,这个程序就是线程不安全的。

如何保证线程安全

那么如何保证线程安全?首先说下众所周知的方法:悲观锁、乐观锁。

什么是悲观锁、乐观锁?在 java 语言里,总有一些名词看语义跟本不明白是啥玩意儿,也就总有部分面试官拿着这样的词来忽悠面试者,以此来找优越感,其实理解清楚了,这些词也就唬不住人了。

  • 乐观锁

    乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

    java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

  • 悲观锁

    悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock

CAS 机制

CAS 是英文单词 Compare And Swap (Compare And Exchange) 的缩写,翻译过来就是比较并替换。

因为经常配合循环操作,直到完成为止,所以泛指一类操作

cas(v, a, b) ,变量v,期待值a, 修改值b

更新一个变量的时候,只有当变量的预期值(计算结果值V) 和主内存当中的实际值(新值 N)相同时,才会将内存地址对应的值 N修改为 V。

ABA

简单说: 值A 变成 B 再变成 A。最终你只会看到A还是A,但是中间变成 B的过程你并不知道,这可能会产生不确定的影响。

解决:版本号( AtomicStampedReference),基础类型简单值不需要版本号每次被修改都改变版本号,每次读都顺带读出版本号。

原子操作类

轻量级锁,所谓原子操作类也称为无锁或者自旋锁,指的是 java.util.concurrent.atomic 包下,一系列以 Atomic 开头的包装类。例如AtomicBooleanAtomicIntegerAtomicLong。它们分别用于BooleanIntegerLong类型的原子性操作。

    private static AtomicInteger count = new AtomicInteger(0);
    ...
                    for (int i = 0; i < 100; i++) {
                        count.incrementAndGet();
                    }

使用 AtomicInteger 之后,最终的输出结果同样可以保证是 200。并且在某些情况下,代码的性能会比 Synchronized 更好。

而 Atomic 操作的底层实现正是利用的 CAS 机制。

unsafe 底层实现

AtomicInteger:

public final int incrementAndGet() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
    }

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

Unsafe:

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

CAS底层的的实现方法是c++写的,底层实现:

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}
  • asm 表示这是汇编指令
  • LOCK_IF_MP:Multi Processors,如果是多个CPU,则前面加LOCK。CAS的关键就是这个LOCK。锁定一个北桥信号。
  • cmpxchg :compare and exchang。(非原子性指令)

但是这种自旋(一直霸占着CPU却不做事)是很耗 cpu 资源的,所以一般情况下,都会有线程 sleep。

CAS 的缺点

1.CPU 开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给 CPU 带来很大的压力。

2. 不能保证代码块的原子性
CAS 机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证 3 个变量共同进行原子性的更新,就不得不使用 Synchronized 了。

Synchronized

  • 同步方法:作用于方法时,锁是当前调用该方法的对象,也就是this指向的对象。

    同步方法是被共享的,那么该方法的对象相当于所有的线程来说就是唯一的,保证了锁的唯一性

  • 作用于静态方法时,锁是该方法所在类的class实例对象,该对象可以通过“类名.class”获取。

    因为Class的相关数据存储在永久代PermGen(jdk1.8则是metaspace),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁。

  • 同步代码块:作用于一个共享实例对象时,锁住的是所有以该对象为锁的代码块。

    (这里需要注意:锁住的是该共享实例对象,而不是下面的代码块!锁的是房间门,而不是床!)

一开始的案例我们用Synchronized同步锁, 只需要在 count++ 的位置添加同步锁,让这个自加的过程捆绑具有原子性,其他线程不能插手这个过程。代码如下:

for (int i = 0; i < 100; i++) {
     synchronized (ThreadCas.class){
         count++;
     }
Synchronized的实现

它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

  1. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
  2. Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
  3. Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
  4. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被称为OnDeck;
  5. Owner:当前已经获取到所资源的线程被称为Owner;
  6. !Owner:当前释放锁的线程。

JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。

Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

缺点

Synchronized虽然确保了线程的安全,但是在性能上却不是最优的Synchronized关键字会让没有得到锁资源的线程进入BLOCKED状态(阻塞或者轮询),而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。

尽管 Java1.6 为Synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。

锁释放的情况

  • 当线程执行完了代码,释放锁

  • 或者执行发生异常,JVM会让线程自动释放锁。

    如果获取到锁的线程在等待IO或者其他原因(比如sleep)被阻塞了,但是又没有释放锁,其他线程只能等着,,,因此需要一种机制可以不让等待的线程无限期的等待。(Lock出现的原因)

Lock

synchronized 无法中断一个正在等候获得锁的线程,也无法通过轮询得到锁,如果不想等下去,也就没法得到锁。JDK5 增加了Lock锁。更加灵活。

Synchronized 与 Lock 的区别

  • 实现层面不一样。synchronized是Java关键字,JVM层面实现加锁和释放锁;

    lock是一个接口,在代码层面实现加锁和释放锁。

  • 是否自动释放锁。synchronized在代码执行完或出现异常时自动释放锁;

    lock不会自动释放锁,需要在finally代码块显式的释放锁 lock.unlock();

  • 是否一直等待。synchronized会导致拿不到锁的线程一直等待;

    lock可以设置尝试获取锁或者获取锁失败一定时间超时。lock.tryLock()

  • 获取锁成功是否可知。synchronized无法得知;

    lock可以通过tryLock 返回布尔值得知。

  • 功能复杂性。synchronized 可重入、不可中断、非公平;

    lock:可重入、可判断、可公平或者不公平、细分读写锁提高效率。

参考:

https://www.jianshu.com/p/ae25eb3cfb5d

http://mashibing.com


  TOC