月泉的博客

并发编程基础(下)

月泉 并发编程

并发编程基础(下)

什么是并发编程

想要知道什么叫并发编程,就先要区分什么是“并发”什么是“并行”,并发是指多件事情同时发生,而并行是指的多件事情执行,一个是侧重于发生,一个是侧重于执行,在单核单线程的CPU上实际上使用多线程是没有太大意义的因为一个CPU的一个超线程同时能够执行的线程中的命令只有一个,且都是时间片轮转机制进行轮转占用的,例如CPU0 -> ThreadA -> ThreadB -> ThreadC 一个CPU对应3条线程,那么其结果就是轮流执行,在随着时代的发展,单核CPU的晶体管已经放不下了,发展成了多核,那么再多核CPU的情况下,再去跑多线程,能够使得一些线程能够并行执行,但确是不一定的,因为系统中不只只有你这个进程中的线程要执行,而且线程的数量总是大于CPU的数量,那么超过了还是得轮转执行,在其行为上还是并发执行并不是并行执行所以在多线程编程上也可以叫做编发编程。

为什么要并发编程

为了更大化的利用多核的机器性能和加速处理海量数据,当然是一把双刃剑,使用并发编程有时不仅不能提高性能反而可能导致降低性能

并发编程所带来的问题

线程安全

多个线程对共享资源进行了操作(未进行同步操作的情况下),其中有一个或多个线程对其产生了写操作,就可能导致线程安全问题,从而导致数据出现脏数据或者不可预见行为。

原子性

一组业务单元在执行时要么全部被执行,要么全部不执行

可见性

由于内存模型的限制,所有变量都放在主存中,每个线程都有自己的工作内存,当要使用主存的变量时,将会从主存copy一份变量副本到工作内存中,当在工作内存更新后才会在更新到主内存中去,这样在单线程下是没有问题的,但是在多线程下就会出现一些问题,例如:线程A先去获取值为5的变量X,首先未在工作内存中命中该值,就会去主存中拷贝至工作内存中,然后将其值修改为10然后同步到主存,这时线程B插进来了获取值为10的变量X,然后将其修改成了15,这时线程A又恢复执行了,由于在线程A中从工作内存中命中了该值为10然后将其加1再同步到了主存,这时候线程B对线程A修改的值是不可见的导致值被修改成了11而不是期望的16.

有序性

为了提高性能允许编译器或者CPU在执行的时候可以不按照代码顺序执行,但是要保证其结果是正确的,这样做在单线程下是没有任何问题的

例如

int a = 3;
int b = 4;
int c = a + b;

无论先执行b还是先执行a都不会导致,c的结果不正确,那么在多线程下呢?

例如

boolean ready = false;
int num = 0;
Thread threadA = new Thread(() -> {
    while(!Thread.currentThread().isInterruputed()){
        if(ready){
            System.out.println(num);
        }
    } 
});
threadA.start();
new Thread(() -> {
    num = 8; // step 1
    ready = true; // step 2
}).start();

Thread.sleep(100);
threadA.interrupt();

暂时先不考虑可见性的问题,在单线程下无论先执行step1和step2都是无所谓的,但是在多线程下就不见得是正确的行为了,如果先执行step2那么是不是会导致线程输出0而不是期望的8?

synchronized

synchronized语义通常称为内部锁,因为其内部的实现我们是看不到的,由虚拟机实现,它还有个名字就是监视器锁,它的主要行为就是一把排它锁,锁定的区域在同一时刻只能有一个线程访问,在加锁的时候就会把要用到的共享变量从工作内存中清空然后又从主存中重新同步,在解锁的时候又会把工作内存中的共享变量同步到主存中去。

volatile

volatile语义主要是保证可见性和有序性,它的主要行为是保证变量修改后能够立即能被其它线程所感知,因为用volatile语义所修饰的变量,在取值或赋值的时候都会直接操作到主存,还有一个有序性就是通过内存屏障,它能够禁止该变量前的所有写操作被排到该变量后面,同时也禁止该变量后的读操作被重排到该变量的前面。

CAS

CAS(Compare And Swap)是通过硬件上来实现读-》改-》写的操作,将内存地址中的值和期望的值所匹配,然后匹配成功则更新成新值,Java中提供了很多种CAS的操作,这里演示其中一种

unsafe.compareAndSwapLong(Test.class, Test.valueOffset, 0, 10);

其参数的含义为compareAndSwapLong(object, valueOffset, except, update),其使用含义为object处的valueOffset的值是否等于except如果等于就替换成update

synchronized/volatile/cas的对比

synchronized

优点

锁机制由JVM实现,使用简单,能够同时保障:原子性、可见性、有序性

缺点

java中的线程和系统线程是一一对应的,由于该锁是一把排它锁会导致其它未获得该锁的线程挂起,导致线程的上下文切换和用户态内核态的切换所带来的开销

使用场景

需要保证一组业务单元的原子性时,使用该锁

volatile

优点

使用简单,能够简单的保证可见性和有序性比synchronized性能要好

缺点

不能保障原子性

使用场景

当要修改的值不依赖当前值,但又要对其它线程可见时或者没有加同步代码的值需要保障其可见性时。

cas

优点

使用简单,性能比锁好,采用乐观锁的机制,不会阻塞线程,利用硬件对单一变量的 读->改->写保障原子性

缺点

不能防止ABA的问题,但是在Java8又提供了一个带时间戳的方案来解决这个问题,那什么是ABA呢?假如有线程1有线程2都对变量X进行操作,变量X的值等于A,首先A线程拿到X的值准备做更换,判断值如果为A就替换成C这时时间片用完了,轮到线程B了线程B先是把这个值改成了B然后又改成了A,那么这时在回到线程A的时候其实这个A已经是被变动后的A了。

如果CAS一次不成功,多次采用自旋CAS长时间反而会极大的损耗CPU的性能其次它仅能保证单一变量的读改写原子性

使用场景

需要保证单一变量修改的原子性时使用

伪共享

CPU为了能够照顾到内存的处理速度采用了分级缓存来获取数据,例如每个核都有L1L2缓存然后所有核共享L3缓存,单CPU计算数据时拿数据会先从L1开始命中如果没有命中就去找L2如果L2也没有命中就去找L3如果L3没有就会从内存中将值同步到缓存上,供CPU使用,但要明白一个概念就是无论是L1、L2、L3缓存的数据都是以缓存行为单位跟内存缓存数据的,例如是64个字节大小的缓存行单位,假如内存中有:long a,b,c,d,那么这个缓存行中就会同时缓存abcd,在得知了CPU是以缓存行为单位来向内存获取数据,那么在多核使用这些缓存数据的时候也会涉及到数据竞争的问题,因为L1、L2缓存是核独有的对核不可见的,所以一般都会采用缓存一致性协议,在修改缓存行中的一个数据时,例如:a、b、c、d,如果修改a,那么这时候这个缓存行同时也只能有一个线程访问其它要访问b、c、d的都会阻塞,直到缓存一致性协议处理完成,在Java中可以利用对齐填充的手段来让一个变量占用一个缓存行进行优化,例如:

class Test{
    long cache = 0;
    long paddingA,paddingB,paddingC,paddingD,paddingE,paddingF;
}

这里再用了6个变量来填充,其次因为对象头也要占8个字节所以 8*8=64(假设缓存行64个字节),在Java8以后又提供了一种新的手段来让一个变量占用一个缓存行仅需要加一条注解即可@Contended但是这个是只供JDK使用的,外部要使用要添加JVM启动参数才可以使用,换一个角度来说缓存行对数据又是好处极大的。

Unsafe

Java提供了一个Unsafe工具类来提供一些底层对硬件的操作,以下是一些常用的方法

staticFieldOffset
objectFieldOffset
arrayBaseOffset
compareAndSwapObject
compareAndSwapInt
compareAndSwapLong
getObjectVolatile
.............

Unsafe直接获取使用会报错,因为它仅允许Bootstrap ClassLoader加载器加载的类使用,如果硬要使用可以利用反射机制获取Unsafe类中的theUnsafe字段

锁的性质

悲观锁

悲观锁处于一种保守的态度,认为数据总是被修改,所以每次修改数据前都要获得锁,才能修改,修改完成后释放锁,供一下个人修改

乐观锁

乐观锁处于一种乐观的态度,认为数据被修改的冲突没有那么频繁,它的概念非常类似于自旋CAS,例如不断的比较我这次修改的数据和数据的被修改前是否一致,通常的做法是用版本号或者时间戳

锁的占用方式

公平锁

公平锁是一种占用的行为,和排队的概念是一样的,先申请锁的资源肯定要比后申请锁的线程先获得锁

非公平锁

非公平锁是指在线程占用锁的机制是无序的不考虑是谁先申请锁谁后申请锁

锁的行为

独占锁

独占锁是指这一把锁同时只能被一个线程占有,其它线程如果要使用这把锁只能挂起等待这把锁的释放

共享锁

共享锁是指这把锁可以同时被多个线程占有,例如很典型的实现Java中的读写锁,读锁可以被多个线程同时持有,而写锁只能被一个线程所持有。

可重入

可重入是指同一个线程是否可以多次持有这把已经获的的锁,Java中可重入锁的原理就是在锁内部维护一个线程标识然后在关联一个计数器

自旋锁

因为线程的挂起阻塞是需要耗费系统的调度资源在用户态和内核态切换,所以当一把锁不能被获取到的时候可以让该线程不挂起,让其执行重复的获取该把锁的行为(跟实现有关,可以自旋一段时间后挂起,也可一直自旋但是非常耗费CPU资源,具体怎么实现根据人来只是概念罢了)

月泉
伪文艺中二青年,热爱技术,热爱生活。