并发编程(一)

一、并发编程与高并发

并发编程与高并发是两个不同的概念,并发编程是相对于以往单线程的编程模型,而采用多线程的编程模型,以充分利用CPU资源。高并发是一类场景,应用的并发请求量非常大的情景,为了解决高并发场景的问题,有很多技术手段,其中多线程并发编程模型是一种技术手段,还有包括扩容、缓存、队列、拆分、服务降级与熔断、数据库分库分表等等。

并发的概念:同时拥有两个或更多的线程,如果是单核CPU,则这多个线程交替获取CPU资源,轮流执行,同一时刻只有一个处于执行状态,其它线程处于其它状态(JVM模型中,线程都有哪些状态?初始状态、就绪状态、运行中状态、阻塞、等待、超时等待、终止);如果是多核CPU,则核心数个线程将同时获取CPU资源,同时执行,多余的线程仍然需要和这些执行中的线程交替获取CPU资源。

谈到并发,主要是考虑多个线程操作相同的资源,保证线程安全,合理调度和使用共享资源,保证正确性。

谈到高并发,主要是考虑能够通过各种技术使得服务器能够同时处理很多请求,提高系统性能。

二、并发编程知识点

线程安全

线程封闭

线程调度

同步容器/并发容器

AQS

J.U.C

三、多线程实现计数器累加

要保证多线程计数器累加的线程安全,关键是++操作的原子性及操作变量的数值可见性,一般情况下,维护一个变量的线程安全,可以考虑Atomic类,该类保证单调用其方法是原子操作,且保证可见性(其内部保存数据的变量由volatile修饰)

加锁也可以实现但是太重,性能不好

public class Counter {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        try {
            for (int i = 0; i < 100; i++){
                executorService.execute(new Runnable() {
                    @Override
                    public void run() {
                        int number = Counter.count.incrementAndGet();
                        System.out.println(Thread.currentThread().getId() + ", " + number);
                    }
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executorService.shutdown();
        }
    }

    private static AtomicInteger count = new AtomicInteger(0);
}

四、CPU缓存

为什么要有CPU缓存?

原本CPU是直接和主内存交互,但是随着CPU频率的不断提高,其与主内存的速度差异越来越大,CPU需要等内存读取,这就浪费了CPU资源。为了缓解二者之间的速度差异,加入了高速缓存(小而快)。

而高速缓存中毕竟只能保存一小部分数据,那么为什么缓存的内容还是有效的呢?

根据局部性原理:时间局部性和空间局部性,目前读取的数据往往在将来还会使用;使用某个位置的数据往往会使用其周围的数据。因此缓存内容是有效的,其内容不会总是失效。

而随着发展,缓存出现多级缓存,为了实现各个CPU缓存之间数据的一致性,引入了MESI协议,缓存中的内容状态有四种,并在这四种之间不断转换,通过该协议保证各个缓存中的数据是一致的。

  • Java内存模型(JMM)

Java内存模型规范规定了,一个线程如何以及何时能够看到其它线程修改过的共享变量的值,以及在必要时如何同步地访问共享变量。

Java内存模型划分为主内存(存储共享变量值,各个线程共享),工作内存(每个线程独有,将主内存中的值读取副本到工作内存,然后进行操作,操作后再写回主内存)

由于各个线程都有自己的工作内存,因此在从主内存读取值后进行操作,可能会出现各个线程操作的结果错乱的问题。为了保证数据的安全,规定了八种同步操作:

Lock、unlock

Read、load、use

Assign、store、write

 

 

而且针对上述八种操作,有一些规定,比如:

Lock只能作用于主内存,只能有一个线程对该变量进行lock,多次lock必须对应多次unlock才释放该变量

Read必须在load之前,read和load必须成对出现,write和store与之类似

Assign到工作内存的值必须在某一时刻通过store和write到主内存,不能随意丢弃

等等很多的规定

五、线程安全性

线程安全性最核心的定义就是正确性。正确性的含义是某个类的行为与其设计完全一致。因此线程安全性的定义是:当多个线程并发访问某个类时,不论运行时环境如何调度或者这些线程将如何交替执行,并且在主调代码中不需要额外的同步措施,这个类始终都能够表现出正确的行为,那么就称这个类是线程安全的。

线程安全性体现在三点:

原子性:提供了互斥访问,一段时间内只能有一个线程来对某个共享变量进行操作

可见性:一个线程对主内存内共享变量的修改能够及时被其它线程看到

有序性:一个线程观察其它线程中指令的执行顺序,由于指令的重排序,其观察结果往往是杂乱无序的

 

 

 

原子性:

总结:需要学习的有Atomic包、CAS算法、synchronized关键字、Lock包

Atomic包中有以下的类:

重点学习以下几个:

AtomicInteger类

AtomicBoolean类

AtomicLong类与LongAdder类的区别

AtomicLongArray类

AtomicReference类、AtomicReferenceFieldUpdater类

AtomicStampReference类:解决CAS的ABA问题

 

 

  • AtomicInteger类

示例代码:

public static AtomicInteger count = new AtomicInteger(0);

private static void add(){

     count.incrementAndGet();//变量操作

}

示例中,对count变量的+1操作,采用的是incrementAndGet方法,此方法的源码中调用了一个名为unsafe.getAndAddInt的方法。

public final int incrementAndGet() {

     return unsafe.getAndAddInt(this, valueOffset, 1) + 1;

}

而Unsafe类中的getAndAddInt方法的具体实现为:

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;

}

在此方法中,方法参数为要操作的对象Object var1底层当前的数值为var2要增加的值为var4。定义的var5为从底层取出来的值。采用do..while循环的方式去比较底层当前值与之前获取的是否一致比较为一致才将值进行修改,否则认为CAS操作失败进行重试。而这个比较再进行修改的方法就是compareAndSwapInt就是我们所说的CAS操作,它是一系列位于Unsafe中的native修饰方法,由底层实现。CAS取的是compareAndSwap三个单词的首字母.

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

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

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

另外,示例代码中的count位于方法中的操作时使用的,可以理解为JMM中的工作内存,而这里的底层数值即为主内存

  • AtomicBoolean类

      与AtomicInteger和AtomicLong类似,只不过维护一个布尔类型的变量,而且通过其方法compareAndSet可以实现控制一段代码只让一个线程执行,AtomicBoolean源码如下,只有AtomicBoolean维护的值等于expect时才会修改为update

public final boolean compareAndSet(boolean expect, boolean update) {
    int e = expect ? 1 : 0;
    int u = update ? 1 : 0;
    return unsafe.compareAndSwapInt(this, valueOffset, e, u);
}

通过上述方法实现一段代码只有一个线程能执行:

//是否发生过
private static AtomicBoolean isHappened = new AtomicBoolean(false);
// 请求总数
public static int clientTotal = 5000;
// 同时并发执行的线程数
public static int threadTotal = 200;

public static void main(String[] args) throws Exception {
    ExecutorService executorService = Executors.newCachedThreadPool();
    //通过信号量控制同时并发的线程数最大为threadTotal
    final Semaphore semaphore = new Semaphore(threadTotal);
    //通过向下计数闭锁来控制一共有clientTotal个线程执行 
    final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
    for (int i = 0; i < clientTotal ; i++) {
        executorService.execute(() -> {
            try {
                 semaphore.acquire();
                 test();
                 semaphore.release();
             } catch (Exception e) {
                 log.error("exception", e);
             }
                countDownLatch.countDown();
         });
    }
    //等待闭锁值减到0
    countDownLatch.await();
    executorService.shutdown();
    log.info("isHappened:{}", isHappened.get());
}

private static void test() {
    //控制某有一段代码只执行一次
    //只有一个线程能将false置为true,其它线程均发现为true无法赋值成功
    if (isHappened.compareAndSet(false, true)) {
        log.info("execute");
    }
}

结果是log只会打印一次,即只有一个线程能够执行test方法中if块中的代码

[main] INFO com.superboys.concurrency.example.Atomic.AtomicExample6 - isHappened:true
  • AtomicLong类与LongAdder类

先来看一看这两个类的操作

AtomicLong:
//变量声明
public static AtomicLong count = new AtomicLong(0);
//变量操作
count.incrementAndGet();
//变量取值
count.get();

LongAdder:
//变量声明
public static LongAdder count = new LongAdder();
//变量操作
count.increment();
//变量取值
count

AtomicLong与AtomicInteger类似,只是表示Long类型的值,那为何还要有LongAdder类呢?

      通过对AtomicInteger的分析我们得知,其内部通过Unsafe的方法来进行线程安全的写入操作,而Unsafe中通过不断CAS尝试直到写入成功,如果并发量比较大,则很多的线程需要进行很多次的CAS尝试,这样效率比较低。(对于基本类型long、double,jvm允许将64位的读写操作变成两个32位的读写操作来完成)

      LongAdder类为了保证在并发比较大的情况下有比较好的性能,内部通过cell数组来保存一个long数值,即将long数值拆分成很多个值分别保存在数组中的每一个位置上,对该数值的加减操作只需要对数组中的一个值进行加减即可,而如果需要获得该long值则需要将数组中的所有值进行求和。该数组默认初始化长度为2。每个线程访问该值时,通过hash算法映射到其中一个数字进行操作。这其实采用了分段锁的思想,提高了并发度,减少冲突的发生。获取其值的方法源码如下

public String toString() {
        return Long.toString(sum());
}

public long sum() {
        Cell[] as = cells; Cell a;
        long sum = base;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
}

从源码中可以看出,求数组的和时并没有任何加锁操作,直接将数组中的值相加求和,如果此时有并发的修改操作,将直接读取到修改的结果。实际中在处理并发度高的时候用LongAdder,而并发度低的时候用实现简单的AtomicLong效率更高。

 

  • AtomicLongArray类

      这个类实际上和AtomicLong区别不大,只是内部维护了一个Long值的数组,而不是一个Long值

 

  • AtomicReference类、AtomicReferenceFieldUpdater类

      AtomicReference类与AtomicInteger、~Long、~Boolean类似,只不过其内部维护一个通过泛型指定的引用类型,比如Integer等等,而AtomicReferenceFieldUpdater可以将一个用户自定义的类中的一个字段封装为原子,并且这个字段必须用volatile修饰、且不能static修饰,而且需要添加get方法

@Slf4j
public class AtomicExample5 {

    //原子性更新某一个类的一个实例
    private static AtomicIntegerFieldUpdater<AtomicExample5> updater
    = AtomicIntegerFieldUpdater.newUpdater(AtomicExample5.class,"count");

    @Getter
    public volatile int count = 100;//必须要volatile标记,且不能是static

    public static void main(String[] args) {
        AtomicExample5 example5 = new AtomicExample5();

        if(updater.compareAndSet(example5,100,120)){
            log.info("update success 1,{}",example5.getCount());
        }

        if(updater.compareAndSet(example5,100,120)){
            log.info("update success 2,{}",example5.getCount());
        }else{
            log.info("update failed,{}",example5.getCount());
        }
    }
}

此方法输出为success 1

[main] INFO com.superboys.concurrency.example.Atomic.AtomicExample5 - update success 1,120
[main] INFO com.superboys.concurrency.example.Atomic.AtomicExample5 - update failed,120
  • AtomicStampReference类

      以上介绍的Atomic类都是通过CAS操作来保证线程安全,但是这些类都没有解决CAS操作的ABA问题,而AtomicStampReference解决了ABA问题,其解决方法是内部维护一个版本号,只要是修改操作,就将版本号增加,这样当出现ABA问题时,线程就能通过版本号发现,从而放弃此次修改操作,也就防止了ABA问题造成影响。

private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
}

private volatile Pair<V> pair;

private boolean casPair(Pair<V> cmp, Pair<V> val) {
        return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
    }

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)));

通过上面的源码可以看出,通过stamp来保存版本号,观察compareAndSet方法可以看到其会对期望值和期望版本号进行比较,不同则会返回(&&是短路与),而且如果新值和新版本号均与旧值旧版本号相等,则直接返回(||也是短路操作),否则通过CAS操作casPair对值和版本号进行赋值。

 

Synchronized关键字是通过JVM层面的锁实现互斥操作,不可以中断的锁,适合竞争不激烈,可读性好;

Lock是类库层面的锁实现互斥操作,可中断,功能多,多样化同步,竞争激烈时优于synchronized;

AtomicXxx类也是类库层面实现的互斥操作,竞争激烈时效果优于Lock,但是功能单一,只能维护单个值

 

synchronized关键字:

修饰代码块,可以指定一个实例对象作为锁,也可以指定一个类(将该类对应的类对象作为锁)作为锁

修饰普通方法,默认调用该方法的实例作为锁,子类继承父类中的方法,方法的synchronized关键字不会继承到

修饰静态方法,默认该方法所在类对应的类对象作为锁

 

 

可见性:

总结:synchronized、volatile

导致共享变量在多个线程之间不可见的原因:

  •       线程交替执行
  •       指令重排序结合线程交替执行
  •       线程对共享变量的修改没有及时从工作内存更新到主内存

Synchronized保证可见性:

JMM对于synchronized有两条规定,加锁时会将线程的工作内存中保存的该共享变量清空,从主内存读取一次,从而得到最新的值;解锁时必须将该线程工作内存中的值更新到主内存

Volatile保证可见性

通过加入内存屏障来禁止指令重排序,以及强制规定线程对volatile变量的读必须从主内存读取(防止工作内存与主内存内容不一致)、对volatile变量的写必须直接将工作内存的值更新回主内存(防止工作内存的值没有及时更新回去,导致内容不一致)来实现。

由以上及java内存模型可以看出,要保证可见性其实很简单,只需要保证工作内存的读写与主内存读写绑定即可,即要读工作内存则先从主内存读最新值再操作,操作后要写工作内存则一定直接写回主内存。

 

有序性:

总结:happens-before原则

JMM中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性

可以通过volatile、synchronized、lock实现有序性

Java本身具有一定的有序性,可以通过happens-before原则来判断两个指令是否本身就有序,无需通过其它方式保证,如果不满足该原则,那么虚拟机可能会对其进行重排序

Happens-before原则:

程序次序规则一个线程内,按照代码书写顺序,书写在前面的操作先行发生于书写在后面的操作。(怎么理解,单线程程序内指令不也会重排序吗?从两个角度考虑,如果两行代码具有先后执行依赖关系,则不会对其进行指令重排序,因此满足上述规则;如果两行代码没有依赖关系,那么可能会进行指令重排序,但是重排序的执行结果和不重排序的执行结果是一致的,因此完全可以认为书写在前的还是先执行的,仍然满足上述规则)

锁定规则,一个unlock操作先行发生于后面对同一个锁的lock操作

Volatile规则,对volatile变量的写操作一定会将工作内存中的值及时更新回主内存,因此后面的读操作一定能读到写操作后的最新值,可以说写先行发生于读

传递规则,A先行发生于B,B先行发生于C,立即推:A先行发生于C

前四条比较重要,后四条比较显而易见

线程启动原则,一个线程的start方法先行发生于该线程中的其它操作

线程中断原则,一个线程interrupt方法先行发生于该线程检测到中断事件的发生

线程终结规则,线程中所有的操作都先行发生于线程的终止检测

对象终结规则,一个对象的初始化先行发生于该对象finalize方法的开始

 

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页