并发和多线程(八)--线程安全、synchronized、CAS简介

一、线程安全性:

  当多个线程访问一个一个对象或者方法的时候,在编写代码的时候,不需要进行额外的处理,就像在单线程环境下一样处理,此时如果还能得到正确的结果,就可以说是线程安全。如果在编写代码的时候,需要进行一些同步的操作(例如使用Synchronized关键字),或者考虑多线程运行的调度和切换(例如read()的时候不能同时set()),那就是线程不安全。

  无状态的对象一定是线程安全的,例如大部分service、dao、Servlet都是无状态的。

二、线程不安全的场景:

1、数据征用

  两个线程同时写数据,结果就是其中一个线程数据丢失,或者写入错误。主要针对共享资源,例如对象的属性、静态变量、共享缓存、数据库操作等。

2、竞争条件

  主要指执行顺序,例如单例模式,肯定要等到new Instance()三个步骤完成,如果提前获得可能会导致空指针异常。

也可以进一步划分为:

1、运行结果错误举个线程不安全最常见的例子,多个线程i++的问题,i++线程不安全的原因如下图。

  i++一共三步, 线程1执行i=1,然后i+1,此时由于不具有可见性,或者说happen-before原则,导致线程2看不到线程1的修改,最终就是每个线程都对i+1,结果为2,都写入的主存,导致i本应该等于3的,还是等于2。下面写代码证明一下,除此之外还要证明什么时候发生了错误,大家可以试试如何实现。

证明i++线程不安全:

public class MyRunnableClass implements Runnable{

    private  int count;
    //正确计算count值
    static AtomicInteger rightCount = new AtomicInteger();
    //发生count++错误的次数
    static AtomicInteger wrongCount = new AtomicInteger();

    final boolean[] flag = new boolean[30000];

    //CyclicBarrier的锁设置为2,因为一种就2两个线程进行i++操作
    CyclicBarrier barrier1 = new CyclicBarrier(2);
    CyclicBarrier barrier2 = new CyclicBarrier(2);

    static MyRunnableClass instance = new MyRunnableClass();

    public static void main(String[] args) throws Exception{
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
        System.out.println("当前计算值为:" + instance.count);
        System.out.println("正确计算值为:" + rightCount);
        System.out.println("count++发生错误次数:" + wrongCount);
    }

    @Override
    public void run() {
        flag[0] = true;
        for (int i = 0; i < 10000; i++) {
            try {
                barrier2.reset();
                barrier1.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
            //前后都有CyclicBarrier,保证两个线程都进行count++之后继续执行
            count++;
            try {
                barrier1.reset();
                barrier2.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
            rightCount.getAndIncrement();
            synchronized (instance) {
                if (flag[count] && flag[count-1]) {
                    System.out.println(count);
                    wrongCount.getAndIncrement();
                }
                flag[count] = true;
            }
        }
    }
}
i++线程不安全
结果:
10587
当前计算值为:19999
正确计算值为:20000
count++发生错误次数:1
结果

  错误发生1次,当count等于10587的时候发生的错误,由于CyclicBarrier和Synchronized(可见性)让发生错误的次数变得很少,否则一般情况下,发生错误的次数可能是几千次。

2、活跃性问题,死锁,活锁饥饿

public class Test {

    static Object lock1 = new Object();
    static Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("获取锁");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("获取锁");
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}
死锁

  上面是一个最简单的死锁的例子,两个线程相互等待对方释放锁,结果造成死锁。

3、对象发布和初始化的时候的线程安全问题

发布:一个类的对象被这个类范围之前的地方使用,例如作为参数传递出去,或者调用方法返回等

逸出:对象被发布到不恰当的地方

1.举个栗子:发布private对象

@Slf4j
public class MyRunnableClass{

    private Map<String, Object> map;

    public MyRunnableClass() {
        map = new HashMap<>();
        map.put("1", "张三");
        map.put("2", "李四");
        map.put("3", "王二");
        map.put("4", "麻子");
    }

    //需要返回当前map,以方便查看内部设置对应关系
    public Map<String, Object> getMap() {
        return map;
    }

    public static void main(String[] args) throws Exception{
        MyRunnableClass aClass = new MyRunnableClass();
        Map<String, Object> map = aClass.getMap();
        log.info("获取key=1的数据为:{}", map.get("1"));
        map.remove("1");
        log.info("获取key=1的数据为:{}", map.get("1"));
    }

}
发布private对象
结果:
 获取key=1的数据为:张三
 获取key=1的数据为:null
结果

  对象被声明为private,就是不想被外部访问或者修改,但是现在有方法直接将引用返回出去,导致可能被线程修改,就像上面的示例。

解决:返回副本,而不是当前引用

//通过返回副本代替直接返回引用,防止被线程修改
    public Map<String, Object> getInsteadMap() {
        return new HashMap<>(map);
    }

    public static void main(String[] args) throws Exception{
        MyRunnableClass aClass = new MyRunnableClass();
        log.info("获取key=1的数据为:{}", aClass.getInsteadMap().get("1"));
        aClass.getInsteadMap().remove("1");
        log.info("获取key=1的数据为:{}", aClass.getInsteadMap().get("1"));
    }
View Code
结果:
 获取key=1的数据为:张三
 获取key=1的数据为:张三
结果

初始化问题:

  1.构造函数初始化未完成,就将this赋值

  2.隐式逸出-注册监听事件

  3.构造函数中运行线程

  初始化的问题导致线程不安全,一般通过工厂模式解决,通过新建工厂类,将之前的类进行实例化,能够保证其初始化完成,再进行后续操作。

三、多线程带来的性能问题:

  我们都知道多线程会提高程序处理效率,但同时也会带来很多问题,不仅仅是线程安全问题,还会有性能问题。

1.上下文切换

  当运行的线程数大于CPU核数,或者类似调用Thread.sleep()都会导致上下文切换。上下文切换需要挂起一个线程,并且保存其状态,以便能够切换回来,主要和寄存器有关。除此之外,还会有缓存的开销,上下文切换很有可能导致缓存失效。

2.缓存失效:

  CPU根据算法,会将有些数据进行缓存,但是上下文切换之后,运行的是新的线程,这些缓存对这个新线程就没有意义了,CPU又需要重新设置缓存。所以,新线程刚被调度的时候速度相对较慢,CPU对两次上下文切换也有最小时间限制的,因为当线程频繁竞争锁,IO读写的时候,上下文切换很频繁。

3.内存同步

  在JMM当中,每个线程有自己的独立内存,通过指令和主存进行同步。

四、线程安全的特性:

1.原子性:

  一系列操作,要么全部执行成功,要么全部不执行,不能分割。不是指一行代码的执行,如果是一段代码不需要额外同步操作,也能保证这段代码全部执行完成,而不会出现执行一部分,后面的代码再不执行了,这同样是原子性。例如常见的i++就不是原子性操作,实际上是三个步骤。

Java中原子操作:

  1.除了long和double之外的基本数据类型的赋值,例如int i = 1;这个操作是无法被其他线程打断的。long和double都是64位的,在32的机器上,一个线程的写入认为是两个32位的单独写入。这样就可以能导致出现线程从一次的写入看到64位值得前32位,另一次写入看到另外32位的情况。官方建议,虚拟机实现尽量一次写入实现,或者通过Synchronized/volatile保证。如果运行在64位的机器上,long和double的赋值是原子性的。PS:当前商用的虚拟机,能够保证long和double的原子性赋值,所以我们在开发中不需要考虑这个问题。

  2.所有引用的赋值,无论32位还是64位机器。

  3.Atomic.*包,CAS实现。

2.可见性:

  一个线程对变量的修改可以及时被其他线程看到。

3.有序性:

  一个线程观察其它线程中的指令执行顺序,由于指令重排序的操作该观察结果一般是无序的。

原子性

1.CAS:

  就是指compareAndSwap/compareAndSet,可以从随便一个Atomic类中看到CAS的实现,例如AtomicInteger的getAndIncrement()

public final int getAndIncrement() {
     return unsafe.getAndAddInt(this, valueOffset, 1);
}
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }

var1:调用当前方法的对象

var5:通过native方法getIntVolatile得到底层的值,然后在while通过compareAndSwapInt判断var2和var5是否相等,因为var5可能被其它线程修改,只有相等的时候,才会进行叠加,这样就可以保证原子性,但还是存在ABA的问题。

CAS缺点:

  并发很高的情况下,实现+1的while循环效率很低,所以出现了LongAdder,LongAdder底层实现和CAS不同,更适合高并发使用,可能会出现计数有误差的情况,低并发和Atomic效率差不多。

2.ABA问题:

  当前变量i=3,线程A执行getAndIncrement(),如果主存的值应该是3,但是被线程B修改为4,然后修改为3,这时候可以执行+1操作的,不过变量被修改过。

解决ABA问题

  可以通过AtomicStampReference去解决,只要变量修改过,变量的版本号加一。

3.Atomic包:实现互斥

Atomic使用类:

  AtomicInteger、AtomicLong、LongAdder(jdk1.8)、AtomicReference、AtomicBoolean、AtomicLongArray(对于数组的原子操作)等。

Atomic核心就是CAS

3.1).AtomicInteger

   通过Semaphore(信号量)和CountDownLatch(闭锁),模拟5000个请求,每次并发处理200个。

// 请求总数
public static int clientTotal = 5000;

// 同时并发执行的线程数
public static int threadTotal = 200;

//    public static AtomicInteger count = new AtomicInteger(0);
public static int count = 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();
                semaphore.release();
            } catch (Exception e) {
                log.error("exception", e);
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    log.info("count:{}", count);
}

private static void add() {
//        count.incrementAndGet();
    // count.getAndIncrement();
    count++;
}
再次证明i++的问题
com.it.TestUnit - count:4809

  结果很明显,存在并发问题,如果把count换成AtomicInteger类型。

com.it.TestUnit - count:5000

3.2).LongAddr:

public class LongAdder extends Striped64 implements Serializable

public void add(long x) {
        Cell[] as; long b, v; int m; Cell a;
        if ((as = cells) != null || !casBase(b = base, b + x)) {
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||
                (a = as[getProbe() & m]) == null ||
                !(uncontended = a.cas(v = a.value, v + x)))
                longAccumulate(x, null, uncontended);
        }
    }

final boolean casBase(long cmp, long val) {
    return UNSAFE.compareAndSwapLong(this, BASE, cmp, val);
}

@sun.misc.Contended static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }
    final boolean cas(long cmp, long val) {
        return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
    }

    // Unsafe mechanics
    private static final sun.misc.Unsafe UNSAFE;
    private static final long valueOffset;
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class<?> ak = Cell.class;
            valueOffset = UNSAFE.objectFieldOffset
                (ak.getDeclaredField("value"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}
LongAddr

  extends了Striped64,Striped64也是jdk1.8出现的,为了原子类服务,有个cell数组,Cell内部有一个非常重要的value变量,并且提供了一个CAS更新其值的方法。

  当竞争不激烈的时候,直接通过CAS更新值成功直接返回,否则就会把atomicLong中对一个value的更新,hash到对应的数组cell节点,然后把value进行汇总,就能提高效率。

  当计数的时候,将base和各个cell元素里面的值进行叠加,从而得到计算总数的目的。在计数的同时如果修改cell元素,有可能导致计数的结果不准确

3.3).AtomicBoolean:使用场景就是让某一段代码只是执行一次

private static AtomicBoolean isHappened = new AtomicBoolean(false);
    if (isHappened.compareAndSet(false, true)) {
        log.info("execute");
    }    

synchronized:每个java对象都可以作为锁

  synchronized关键字是不能继承的,父类是synchronized方法,子类必须显示写出来,否则不是同步方法。

1.作用范围:

  1).修饰非静态方法:synchronized作用于单个对象的,不同的对象之间是不影响的,应该是不同对象交叉执行的。

  2).修饰修饰静态方法:作用的所有的对象,同一时间只有一个对象获得对象监视器,不同对象调用,应该是等一个对象执行完成,另一个对象才会执行方法。

  3).修饰同步方法块:锁是Synchonized括号里配置的对象。

2.实现原理:基于Monitor实现同步的

  synchronized获取对象锁保证在执行共享数据的线程是互斥的,可以使用wait、notify、notifyAll进行线程协同工作、Class和Object都关联了一个Monitor。

3.具体实现:

  1、提供了两个高级的字节码指令monitorenter和monitorexit。这两个字节码实现了同步代码块。

  2、提供了ACC_SYNCHRONIZED标记符隐式实现了同步方法。

public class TestUnit{

    public synchronized void test1() {
        System.out.println("this is test1 method");
    }

    public void test2() {
        synchronized (this) {
            System.out.println("this is test2 method");
        }
    }

}
Synchronized

通过javac编译出class文件,后javap反汇编出字节码

4.锁存放的位置:

  锁标记存放在java对象头的Mark Word中。

Lock和synchronized有以下几点不同:

  1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现。

  2)synchronized在发生异常时,会自动释放线程占有的锁(独占锁、也是一种悲观锁),因此不会导致死锁现象发生而Lock在发生异常时,如果没有主动通过unLock()去释放锁(乐观锁),则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。

  3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

  4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

  5)Lock可以提高多个线程进行读操作的效率。

Atomic:竞争激烈也能保持常态,比Lock性能好,但是每次只能同步一个值。

可见性

  一个线程的操作无法被其他线程感知到,就不具有可见性,原因是:CPU核寄存器和主存之间有多级缓存,而在JMM中规定,每个线程都有自己的本地内存,本地内存和主存之间需要同步,但不是实时同步,就会存在可见性问题。

代码测试:

public class MyRunnableClass{

    int a = 1, b = 2;

    public void change() {
        a = 3;
        b = a;
    }

    public void print() {
        # System.out.println("a = " + a + ",b = " + b);
        System.out.println("b = " + b + ",a = " + a);
    }

    public static void main(String[] args) {
        while (true) {
            MyRunnableClass aClass = new MyRunnableClass();
            Thread thread1 = new Thread(() -> {
                try {
                    Thread.sleep(1);
                    aClass.change();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            Thread thread2 = new Thread(() -> {
                try {
                    Thread.sleep(1);
                    aClass.print();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });

            thread1.start();
            thread2.start();
        }
    }

}
可见性问题
可能结果:
#这里默认不考虑乱序执行的问题,不然我都不知道怎么代码举例了
b = 3,a = 1    可见性导致
b = 3,a = 3    线程1赋值完成,线程2在打印
b = 2,a = 3    线程1执行b = 3,没来及将a赋值给b,线程2就打印了
b = 2,a = 1    先打印,后赋值

  上面代码一个线程进行赋值操作,一个进行打印,四种情况都有可能,其中b = 3,a = 1因为线程2打印只看到了b的最新值,而从主存中获取了过期值,出现了可见性问题。结果方法就是将b用volatile修饰,a可以不用添加volatile修饰,因为volatile关键字具有传递性,对变量b读取时,我们能看到所有对变量b之前的所有操作。注意,println打印顺序,因为它不是原子性操作,如果先打印a,后打印b,很有可能只是打印了a,线程切换,然后对b进行赋值,导致出现出现a = 1,b = 3的情况,让你以为volatile没有作用。

Happens-before原则:

  提到可见性肯定就会说道Happens-before原则,它的定义是:时间上,动作A发生在动作B之前,保证B能看到A,可以理解为线程A对共享变量的操作,线程B肯定可以及时看到。

Happens-before具有的规则:

  1.单线程:单线程环境下肯定不会出现可见性问题。

  2.锁操作(Synchronized和lock):锁的相关操作同样可以保证具有可见性,线程A加锁-操作-解锁,线程B加锁,这时候线程B肯定能看到线程A之前的所有操作。

  3.volatile:

  4.线程启动:

  5.join:join()后面的语句能看到之前的操作。

  6.传递性:前面说了。

  7.中断:对线程中断的判断,肯定具有可见性。

  8.构造函数:

  9.并发工具类对Happens-before的支持,ConcurrentHashmap,CountDownLatch,Semaphore,CycliBarrier、Future、线程池

1.导致共享变量在线程间不可见的原因

  1、线程交叉执行。

  2、重排序结合线程交叉执行。

  3、共享变量的值没有在工作内存和主存之间及时更新。

2.JMM对于synchronized规定:

  线程解锁前,必须把共享变量的最新值刷新到主存。

  线程加锁时,要把工作内存中的共享变量值清空,从而使用时从主存中读取最新变量的值。

  PS:加锁和解锁,是同一把锁

3.volatile如何实现可见性

  不具有原子性,无法保证线程安全,只能修饰变量,多线程下不会发生阻塞。

1.通过加入内存屏障和禁止重排序优化(实现有序性)来实现。

2.对volatile变量进行读写操作,都会通过store、load来强制从主存中读取最新的值,或将数据强制刷新到主存中。

4.使用场景

  1、不适合计数场景

  2、适合作为状态标示量,多线程下作为flag

  3、doubleCheck

  4、Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

  5、除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。只不过实现方式不同。

有序性

  有序性对应着就是指令重排和乱序执行的解决,通过禁止指令重排达到有序性。

重排序:

  线程执行代码不是严格按照代码语句顺序执行,顺序发生变化。下面通过代码展示什么是重排序:

public class MyRunnableClass{

    private static int x = 0, y = 0, a = 0, b = 0;

    public static void main(String[] args){

        int i = 0;
        while (true) {
            //CountDownLatch用来保证两个线程的赋值操作,尽可能同时
            CountDownLatch countDownLatch = new CountDownLatch(1);
            a = 0;
            b = 0;
            x = 0;
            y = 0;
            i++;
            Thread thread1 = new Thread(() -> {
                try {
                    countDownLatch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = 1;
                x = b;
            });
            Thread thread2 = new Thread(() -> {
                try {
                    countDownLatch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                b = 1;
                y = a;
            });

            thread1.start();
            thread2.start();

            countDownLatch.countDown();

            try {
                thread1.join();
                thread2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            String result = "第" + i + "次(" + x + "," + y + ")";
            if (x == 0 && y == 0) { //x和y同时为0的情况,就是发生重排序(也可能是可见性导致)
                System.out.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }

}
重排序
结果:
第325次(0,1)
第326次(0,1)
第327次(0,0)

在第327次时发生重排序(也可能是可见性导致),导致x和y的打印结果都是0。

重排序的好处:

  上面的代码让我们看到了重排序的坏处,但是重排序也是有好处的,否则就不会让它存在了,重排序可以根据算法对代码指令进行优化,可以提高程序处理速度。

重排序的3中情况:

1.编译器优化:包括JVM,JIT编译器等,都是为了优化代码的执行。

2.CPU指令重排:CPU和编译器优化很类似,是通过乱序执行的技术,来提高执行效率。

3.内存的"重排序":内存系统内实际上不存在重排序,但是内存会带来看上去和重排序一样的效果。由于内存有缓存的存在,在JMM里表现为主存和本地内存,由于主存和本地内存的不一致,会使得程序表现出乱序的行为。 

volatile、synchronized、lock都能保证有序性

1.只要满足happens-before原则,就能保证先天的有序性,否则就可能发生指令重排序。

2.synchronized和volatile保证有序性实现方式的区别:

  1).volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

  2).synchronized好像是万能的,三个特性都可以保证,但是比较影响性能,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

原文地址:https://www.cnblogs.com/huigelaile/p/10844592.html