高并发系列(三)--线程安全性详解(原子性)

11 篇文章 0 订阅
4 篇文章 0 订阅
本文详细探讨了线程安全性和原子性在多线程环境中的重要性。线程安全的三个关键属性包括原子性、可见性和有序性。以AtomicInteger为例,解释了如何利用CAS(Compare and Swap)实现线程安全的无锁更新。尽管CAS在某些场景下高效,但也存在循环时间过长、只能保证单个变量原子操作以及ABA问题等缺点。此外,文章还讨论了锁(synchronized和Lock)在确保线程安全方面的作用,以及它们各自的优缺点和使用场景。
摘要由CSDN通过智能技术生成

 

一、概念

    1.定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

    2.线程安全性:

  原子性:提供了互斥访问,同一时刻只能有一个线程来对它进行操作。 

  可见性:一个线程对主内存的修改可以及时的被其他线程观察到。 

  有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排排序的存在,该观察结果一般杂乱无序。

二、原子性-Atomic

 

    1.原子性--Atomic包

    原子英文单词为:atomic,刚刚好Java下定义了这样的类,比如:AtomicXXX:CAS;AtomicLong、LongAdder。

 

    2.为什么要使用这个呢?或者说在什么场景下使用呢?

    在并发场景中,当多线程需要对同一份资源做操作时,就会产生线程安全问题。以最简单的int i++为例,i++并不是原子操作,编译出来后分为三步:1,获取值;2,修改值;3,设置值。如果有多线程执行i++,则通常不会得到正确的结果。举例如下:

/** * @author 繁荣Aaron */public class ActiomTest {    static Logger logger = LoggerFactory.getLogger(ActiomTest.class);
    private static int n = 0;
    public static void main(String[] args) throws Exception {        Thread t1 = new Thread() {            @Override            public void run() {                for (int i = 0; i < 1000; i++) {                    n++;                    try { Thread.currentThread().sleep(10); } catch (InterruptedException e) { }                }            }        };        Thread t2 = new Thread() {            @Override            public void run() {                for (int i = 0; i < 1000; i++) {                    n++;                    try { Thread.currentThread().sleep(10); } catch (InterruptedException e) { }                }            }        };        t1.start();        t2.start();        t1.join();        t2.join();
        logger.info("n = {}", n);    }}

    结果如下:

    并不是我们所需要的结果:2000。所以必须用方法进行解决。

    解决方式,如下:

 1.使用synchronized关键字,具体使用参考先前的文章

(https://my.oschina.net/u/2380961/blog/1594040)。   

 2.JDK并发包里提供了很多线程安全的类。如:int对应线程安全的AtomicInteger。类似的还有:AtomicBooleanAtomicLongAtomicReference

 

    3.如何使用?

    举例,如下:

public class AtomicExample1 {    // 请求总数    public static int clientTotal = 5000;    // 同时并发执行的线程数    public static int threadTotal = 200;    public static AtomicInteger count = new AtomicInteger(0);    public static void main(String[] args) throws Exception {        ExecutorService executorService = Executors.newCachedThreadPool();        final Semaphore semaphore = new Semaphore(threadTotal);        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);        for (int i = 0; i < clientTotal ; i++) {            executorService.execute(() -> {                try {                    //semaphore.acquire();                    //add();                    count.incrementAndGet();                    //semaphore.release();                } catch (Exception e) {                    e.printStackTrace();                }                //countDownLatch.countDown();            });        }        //countDownLatch.await();        executorService.shutdown();        System.out.println(count.get());    }    private static void add() {        int i = count.incrementAndGet();        // count.getAndIncrement();        //System.out.println(i);    }}

    一开始,去掉Semaphore CountDownLatch 两个工具类。总共执行5000次,那么输出的结果也应该是5000。但是真实结果却不是,如果多执行几次机会出现如下的错误,结果却是4997:

    所以上面如果缺少CountDownLatch 这个工具类,是无法达到线程安全的,就算是AtomicInteger类。具体原因,我没有弄清楚,就算是加上volatile关键字也不行的:

    只要打开了CountDownLatch 关键字才可以,下面的程序是线程安全的:

  •  
  •  
@ThreadSafepublic class AtomicExample1 {    // 请求总数    public static int clientTotal = 5000;    // 同时并发执行的线程数    public static int threadTotal = 200;    public static AtomicInteger count = new AtomicInteger(0);    public static void main(String[] args) throws Exception {        ExecutorService executorService = Executors.newCachedThreadPool();        final Semaphore semaphore = new Semaphore(threadTotal);        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);        for (int i = 0; i < clientTotal ; i++) {            executorService.execute(() -> {                try {                    //semaphore.acquire();                    //add();                    count.incrementAndGet();                    //semaphore.release();                } catch (Exception e) {                    e.printStackTrace();                }                countDownLatch.countDown();            });        }        countDownLatch.await();        executorService.shutdown();        System.out.println(count.get());    }    private static void add() {        int i = count.incrementAndGet();        // count.getAndIncrement();        //System.out.println(i);    }}

    #API

public final int get() //获取当前的值public final int getAndSet(int newValue)//获取当前的值,并设置新的值public final int getAndIncrement()//获取当前的值,并自增public final int getAndDecrement() //获取当前的值,并自减public final int getAndAdd(int delta) //获取当前的值,并加上预期的值integer.incrementAndGet(); //先+1,然后在返回值,相当于++i  integer.decrementAndGet();//先-1,然后在返回值,相当于--i  integer.addAndGet(1);//先+n,然后在返回值,  

    总结:

1.使用的是线程池技术,特别需要注意的是就算是AtomicInteger类如果是单独的使用,也是线程不安全的。关于上面的原因 为什么会导致线程不安全后面讲了线程池在叙说。    

2.CountDownLatch 关键字只是保证了线程的执行,并不线程的原子性,那么到底是什么原因使AtomicInteger保持原子性呢?

    4.atomic原理之CAS

    CAS,Compare and Swap即比较并交换,设计并发算法时常用到的一种技术,java.util.concurrent包完全建立在CAS之上,没有CAS也就没有此包,可见CAS的重要性。

    当前的处理器基本都支持CAS,只不过不同的厂家的实现不一样罢了。CAS有三个操作数:内存值V、旧的预期值A、要修改的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做并返回false。当然更加底层的,就是Unsafe实现的,看下Unsafe下的三个方法:

public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);
#该方法为本地方法,有四个参数,分别代表:对象、对象的地址、预期值、修改值public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);

    Java内部原理代码,如下:

private static final Unsafe unsafe = Unsafe.getUnsafe();private static final long valueOffset;
static { try {    valueOffset = unsafe.objectFieldOffset        (AtomicInteger.class.getDeclaredField("value"));  } catch (Exception ex) { throw new Error(ex); }}
private volatile int value;

    这里, unsafe是java提供的获得对对象内存地址访问的类,注释已经清楚的写出了,它的作用就是在更新操作时提供“比较并替换”的作用。实际上就是AtomicInteger中的一个工具。

    valueOffset是用来记录value本身在内存的便宜地址的,这个记录,也主要是为了在更新操作在内存中找到value的位置,方便比较。

    注意:value是用来存储整数的时间变量,这里被声明为volatile,就是为了保证在更新操作时,当前线程可以拿到value最新的值(并发环境下,value可能已经被其他线程更新了)。

    下面找一个方法getAndIncrement来研究一下AtomicInteger是如何实现的,比如我们常用的addAndGet方法:

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

这段代码如何在不加锁的情况下通过CAS实现线程安全,我们不妨考虑一下方法的执行:

    1、AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据Java内存模型,线程1和线程2各自持有一份value的副本,值为3

    2、线程1运行到第三行获取到当前的value为3,线程切换

    3、线程2开始运行,获取到value为3,利用CAS对比内存中的值也为3,比较成功,修改内存,此时内存中的value改变比方说是4,线程切换

    4、线程1恢复运行,利用CAS比较发现自己的value为3,内存中的value为4,得到一个重要的结论-->此时value正在被另外一个线程修改,所以我不能去修改它

    5、线程1的compareAndSet失败,循环判断,因为value是volatile修饰的,所以它具备可见性的特性,线程2对于value的改变能被线程1看到,只要线程1发现当前获取的value是4,内存中的value也是4,说明线程2对于value的修改已经完毕并且线程1可以尝试去修改它

    6、最后说一点,比如说此时线程3也准备修改value了,没关系,因为比较-交换是一个原子操作不可被打断,线程3修改了value,线程1进行compareAndSet的时候必然返回的false,这样线程1会继续循环去获取最新的value并进行compareAndSet,直至获取的value和内存中的value一致为止

    整个过程中,利用CAS机制保证了对于value的修改的线程安全性。

    CAS的缺陷

    CAS虽然高效地解决了原子操作,但是还是存在一些缺陷的,主要表现在三个方法:循环时间太长、只能保证一个共享变量原子操作、ABA问题。

    循环时间太长

    如果CAS一直不成功呢?这种情况绝对有可能发生,如果自旋CAS长时间地不成功,则会给CPU带来非常大的开销。在JUC中有些地方就限制了CAS自旋的次数,例如BlockingQueue的SynchronousQueue。

    只能保证一个共享变量原子操作

    看了CAS的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了,当然如果你有办法把多个变量整成一个变量,利用CAS也不错。例如读写锁中state的高地位

    ABA问题

    CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的ABA问题。对于ABA问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加1,即A —> B —> A,变成1A —> 2B —> 3A。

    缺陷的解方式:CAS的ABA隐患问题,解决方案则是版本号,Java提供了AtomicStampedReference来解决。AtomicStampedReference通过包装[E,Integer]的元组来对对象标记版本戳stamp,从而避免ABA问题。对于上面的案例应该线程1会失败。

#四个参数,分别表示:预期引用、更新后的引用、预期标志、更新后的标志public boolean compareAndSet(V   expectedReference,                                 V   newReference,                                 int expectedStamp,                                 int newStamp) {        Pair<V> current = pair;        return            expectedReference == current.reference &&            expectedStamp == current.stamp &&            ((newReference == current.reference &&              newStamp == current.stamp) ||             casPair(current, Pair.of(newReference, newStamp)));    }

    代码案例:

  Thread tsf1 = new Thread(new Runnable() {            @Override            public void run() {                try {                    //让 tsf2先获取stamp,导致预期时间戳不一致                    TimeUnit.SECONDS.sleep(2);                } catch (InterruptedException e) {                    e.printStackTrace();                }                // 预期引用:100,更新后的引用:110,预期标识getStamp() 更新后的标识getStamp() + 1                atomicStampedReference.compareAndSet(100,110,atomicStampedReference.getStamp(),atomicStampedReference.getStamp() + 1);                atomicStampedReference.compareAndSet(110,100,atomicStampedReference.getStamp(),atomicStampedReference.getStamp() + 1);            }        });
        Thread tsf2 = new Thread(new Runnable() {            @Override            public void run() {                int stamp = atomicStampedReference.getStamp();
                try {                    TimeUnit.SECONDS.sleep(2);      //线程tsf1执行完                } catch (InterruptedException e) {                    e.printStackTrace();                }                System.out.println("AtomicStampedReference:" +atomicStampedReference.compareAndSet(100,120,stamp,stamp + 1));            }        });
        tsf1.start();        tsf2.start();

三、原子性-锁

 

    锁,主要讲两个关键字:synchronized(依赖JVM);Lock(依赖特殊的CPU指令,代码实现 ,ReentrantLock)。

    1.synchronized

    synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。具体的使用参考博客地址(https://my.oschina.net/u/2380961/blog/1594040)。

    使用需要主要的地方:

修饰代码块:大括号括起来的代码,作用于调用的对象。 

修饰方法:整个方法,作用于调用的对象。 

修饰静态方法:整个静态方法,作用于所有对象。 

修饰类:括号括起来的部分,作用于所有对象。

    当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:

 

public class SynchronizedTest {    public synchronized void test1(){
    }
    public void test2(){        synchronized (this){
        }    }}

    利用javap工具查看生成的class文件信息来分析Synchronize的实现:

    从上面可以看出,同步代码块是使用monitorenter和monitorexit指令实现的,同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。具体体现,如下:

    进入,获取锁:

    每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。


   释放锁:  

   执行monitorexit的线程必须是objectref所对应的monitor的所有者。

   指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。 

 通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

   同步代码块:monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;

  同步方法:synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。

(摘自:http://www.cnblogs.com/javaminer/p/3889023.html)

   

    2.Lock

    首先,这篇将不介绍Lock的使用,具体的APi使用参考这边博客地址:Java多线程知识点整理(Lock锁):

https://my.oschina.net/u/2380961/blog/1595357

@Slf4j@ThreadSafepublic class LockExample2 {    // 请求总数    public static int clientTotal = 5000;    // 同时并发执行的线程数    public static int threadTotal = 200;    public static int count = 0;    private final static Lock lock = new ReentrantLock();    public static void main(String[] args) throws Exception {        ExecutorService executorService = Executors.newCachedThreadPool();        final Semaphore semaphore = new Semaphore(threadTotal);        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);        for (int i = 0; i < clientTotal ; i++) {            executorService.execute(() -> {                try {                    semaphore.acquire();                    add();                    semaphore.release();                } catch (Exception e) {                    //log.error("exception", e);                }                countDownLatch.countDown();            });        }        countDownLatch.await();        executorService.shutdown();        //log.info("count:{}", count);    }
    private static void add() {        lock.lock();        try {            count++;        } finally {            lock.unlock();        }    }}

    2.1 为什么要使用Lock锁?

    Java的内置锁一直都是备受争议的,在JDK 1.6之前,synchronized这个重量级锁其性能一直都是较为低下,虽然在1.6后,进行大量的锁优化策略,但是与Lock相比synchronized还是存在一些缺陷的:虽然synchronized提供了便捷性的隐式获取锁释放锁机制(基于JVM机制),但是它却缺少了获取锁与释放锁的可操作性,可中断、超时获取锁,且它为独占式在高并发场景下性能大打折扣。

    AbstractQueuedSynchronizer,简称AQS,是java.util.concurrent的核心,CountDownLatch、FutureTask、Semaphore、ReentrantLock等都有一个内部类是这个抽象类的子类。由于AQS是基于FIFO队列的实现,因此必然存在一个个节点,Node就是一个节点,Node里面有很多方法。

 

    整个AQS是典型的模板模式的应用,设计得十分精巧,对于FIFO队列的各种操作在AQS中已经实现了,AQS的子类一般只需要重写tryAcquire(int arg)和tryRelease(int arg)两个方法即可。

 

    AQS的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。

    AQS使用一个int类型的成员变量state来表示同步状态,当state>0时表示已经获取了锁,当state = 0时表示释放了锁。它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对同步状态state进行操作,当然AQS可以确保对state的操作是安全的。

    AQS通过内置的FIFO同步队列来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。

 2.2、AbstactQueuedSynchronizer的基本数据结构

    1.AbstractQueuedSynchronizer的等待队列是CLH队列的变种,CLH队列通常用于自旋锁,AbstractQueuedSynchronizer的等待队列用于阻塞同步器。

    2.每个节点中持有一个名为"status"的字段用于是否一条线程应当阻塞的追踪,但是status字段并不保证加锁。

    3.一条线程如果它处于队列头的下一个节点,那么它会尝试去acquire,但是acquire并不保证成功,它只是有权利去竞争。

    4.要进入队列,你只需要自动将它拼接在队列尾部即可;要从队列中移除,你只需要设置header字段。

    2.3、AbstractQueuedSynchronizer供子类实现的方法

    AbstractQueuedSynchzonizer是基于模板模式的实现,不过它的模板模式写法有点特别,整个类中没有任何一个abstract的抽象方法,取而代之的是,需要子类去实现的那些方法通过一个方法体抛出UnsupportedOperationException(集合的使用也会抛出这个异常)异常来让子类知道。

    这个类的acquire不好翻译,所以就直接原词放上来了,因为acquire是一个动词,后面并没有带宾语,因此不知道具体acquire的是什么。按照我个人理解,acquire的意思应当是根据状态字段state去获取一个执行当前动作的资格。

    比如ReentrantLock的lock()方法最终会调用acquire方法,那么:   

 1.线程1去lock(),执行acquire,发现state=0,因此有资格执行lock()的动作,将state设置为1,返回true。 

 2.线程2去lock(),执行acquire,发现state=1,因此没有资格执行lock()的动作,返回false。

    2.4、独占模式acquire实现流程

    看一下AbstractQuueuedSynchronizer的acquire方法实现流程,acquire方法是用于独占模式下进行操作的:

public final void acquire(int arg) {      if (!tryAcquire(arg) &&          acquireQueued(addWaiter(Node.EXCLUSIVE), arg))         selfInterrupt();    }

    tryAcquire方法前面说过了,是子类实现的一个方法,如果tryAcquire返回的是true(成功),即表明当前线程获得了一个执行当前动作的资格,自然也就不需要构建数据结构进行阻塞等待。

 

    如果tryAcquire方法返回的是false,那么当前线程没有获得执行当前动作的资格,接着执行"acquireQueued(addWaiter(Node.EXCLUSIVE), arg))"这句代码,这句话很明显,它是由两步构成的:

addWaiter,添加一个等待者。

    acquireQueued,尝试从等待队列中去获取执行一次acquire动作。

利用LockSupport(这个使用到了sun.misc.Unsafe UNSAFE;)的park方法让当前线程阻塞。

总结:这个方法的主要目的是为了构建成一个数据结构,同时获取锁的状态。

    2.5、独占模式release流程

    上面整理了独占模式的acquire流程,看到了等待的Node是如何构建成一个数据结构的,下面看一下释放的时候做了什么,release方法的实现为:​​​​​​​

public final boolean release(int arg) {     if (tryRelease(arg)) {         Node h = head;         if (h != null && h.waitStatus != 0)             unparkSuccessor(h);         return true;     }     return false; }

 

    tryRelease同样是子类去实现的,表示当前动作我执行完了,要释放我执行当前动作的资格,讲这个资格让给其它线程,然后tryRelease释放成功,获取到head节点,如果head节点的waitStatus不为0的话,执行unparkSuccessor方法,顾名思义unparkSuccessor意为unpark头结点的继承者。

    流程:

1.头节点的waitStatus<0,将头节点的waitStatus设置为0 。

2.拿到头节点的下一个节点s,如果s==null或者s的waitStatus>0(被取消了),那么从队列尾巴开始向前寻找一个waitStatus<=0的节点作为后继要唤醒的节点。

最后,如果拿到了一个不等于null的节点s,就利用LockSupport的unpark方法让它取消阻塞。

 

总结

 

一、对比

 

synchronized:不可中断锁,适合竞争不激烈,可读性好。    

Lock:可中断锁,多样化同步,竞争激烈时能维持常态。    

Atomic:竞争激烈时能维持常态,比Lock性能好,只能同步一个值。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值