Java并发问题总结

/ 语言 / 没有评论 / 340浏览

Java并发问题总结!

-----------------来自小马哥的故事

Java内存模型

所有变量都存储在主内存中。这里的主内存只是虚拟机内存的一部分,可以和物理主内存类比。每条线程都有自己的工作内存。工作内存可以和处理器高速缓存类比。工作内存中保存了主内存中变量的拷贝,线程所有的操作只能在工作内存中进行,不同线程不能访问对方的工作内存,只能通过更新到主内存中的方式来传递线程间的变量值。

主内存与工作内存间的交互操作都具有原子性,包括

其中read和load之间,store和write之间必须按顺序执行,但是不要求连续执行,即中间可以插入其他指令。

并发的三个问题

原子性

指的是不能被线程调度机制中断的操作,它会在上下文切换之前执行完毕。由于read,load,store,write,use,assign都能够保证原子性,故对一个基本类型变量的访问和赋值可以看作原子操作。对于synchronized块之间的操作也具有原子性。

x = 1; // 具有原子性
y = x; // 2个指令,use了x的值,再assign到y
x++; // 4个指令,use了x的值,生成常数1,x加1,再assign到x

可见性

指的是当一个线程修改了共享变量值,其他线程能够立即得知这个修改。

普通变量的修改首先发生在本线程的工作内存中,这会导致各个工作内存的不一致性。当一个线程结束后会将各自的工作内存同步回主内存,另一个线程读取这个变量时会从主内存中读取它的新值。

volatile变量也是同样的过程,只是它修改后立即同步回主内存,并通知其他工作内存中的此变量失效。如果其他线程需要使用此变量时,只能从主内存中重新读取它的新值。这就保证了多线程下的变量可见性。

synchronized同步块也具有可见性。这是由于对一个变量unlock之前,必须先将它同步回主内存中。

final修饰的变量也具有可见性。当一个final变量被初始化后(构造器完成之前没有将this引用传递出去),此变量在其他线程中可见。

有序性

指的是机器会对指令进行重排序来达到运行时的优化。这就导致了代码书写上的先后顺序不能在执行时得到保证,但是在单线程内看,程序执行的结果和按照串行执行的结果保持一致。而使用多线程时执行结果就不能保证了。Java可以有两种方法保证线程间的有序性

volatile可以防止指令随意的重排序,它的作用相当于一个内存屏障,也就是重排序时不能将内存屏障之后的指令排在它之前。

synchronized同步块可以保证有序,是因为同一时刻只允许一个线程对某个变量进行lock操作。因此多个线程只能有序的进入同一个同步块。

volatile关键字

volatile是最轻量级的同步机制,但是它只保证了被修饰变量的可见性和有序性,而不能保证原子性,从而不能解决很多并发同步问题。

具有两个特性

可见性,一旦某个线程中的volatile变量被修改,即store和write 指令执行后,所有线程都可以得到最新的变量值。注意这里是指令执行,而不是Java的语句执行,一条非原子性的Java语句总是对应多条指令,所以这条语句所带来的改变不具有可见性。 有序性,防止指令随意的重排序优化。通过在汇编代码中加入lock操作,使变量的修改写回主内存,即执行了store和write指令。这意味着写之前的所有操作都必须执行完成,从而达到了内存屏障的作用。同时它还会使其他工作内存中的该变量无效,其他线程需要重新从主内存中读取此变量。

应用场合

由于volatile能够保证可见性和有序性,唯一不能保证原子性,因此如果一个操作本身具有原子性,那么使用volatile修饰后就可以保证并发的同步性。应用场合有两个

变量的赋值不依赖于它的当前值或别的变量的当前值,即直接使用assign指令而没有使用use指令,具有原子性 保证只有一个线程对变量进行修改,而别的线程只进行读取,读取值不一定是最新的,但修改不会出错

synchronized关键字

可以解决所有并发问题,但是容易造成滥用而导致并发性能不高。可以作为方法的修饰符,表示要进入方法时需要获取本对象的锁,也可以使用synchronized(object){...}对代码块加锁,表示要进入代码块时需要获取object的锁。 一旦有一个线程获取了锁而进入了同步块中,所有的其他线程都会进入等锁池而阻塞。这有时会导致不必要的阻塞时间。同时,Java线程是映射到操作系统原生线程上的,阻塞和唤醒都需要从用户态转换到内核态,要花费较多处理器时间。而volatile变量的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

重入锁ReentrantLock

ReentrantLock可以显式的创建,锁定和释放,与synchronized的内建锁复杂但是更灵活,尤其是进入同步块后如果抛出异常,可以进行清理工作。另外还可以实现一些高级功能,包括等待可中断(线程可以放弃等待而做其他事情),公平锁(按照申请锁的时间获取锁),多条件绑定(通过调用newCondition()方法添加多个Condition)。


Lock lock = new ReentrantLock();
lock.lock();
try {
    ...
} finally {
    lock.unlock();
}

ReentrantLock lock = new ReentrantLock();
boolean captured = lock.tryLock();
try {
    ...
} finally {
    if (captured) lock.unlock();
}

Atomic类

加锁属于阻塞同步,即无论共享数据是否真的出现竞争都会加锁,这是悲观的并发策略。而利用硬件指令还可以实现非阻塞同步,这是一种基于冲突检测的乐观并发策略。它可以先操作,如果没有其他线程争用共享数据则操作成功,而如果产生了冲突,则不断重试直到操作成功。这涉及一条处理器指令compare-and-swap(CAS),它具有原子性,表示变量符合旧值时才会用新值更新变量,否则不更新,最后都返回旧值。 例如Java8中AtomicInteger类的incrementAndGet()方法,会用到sun/misc/Unsafe类中的getAndAddInt()方法,其中的compareAndSwapInt()就是CAS操作。下述代码的意义是对于实例var1在偏移var2处的旧值为var5,如果即将要赋值的时候发现获取的值不符合var5(CAS指令操作),说明此时有其他线程已经修改了这个变量,于是继续获取新的var5,直到赋值前获取的值符合var5,则用var5+var4更新var5。


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

不使用同步的情况

可重入代码不需要同步,因为它不使用共享变量,且所有的状态量都由参数传入,所以在任何时刻中断它再返回后不会出现错误,这就保证了线程安全。

如果能保证共享变量只在一个线程中可见,同样也不需要同步,但是这样的应用比较少见。

利用ThreadLocal可以根除了对变量的共享,它可以为使用相同变量的每个线程创建不同的存储。每个线程使用的都是独立的变量,当然不会有同步的问题。但是一个线程是不能访问到另一个线程的ThreadLocal变量,尽管只创建过一个ThreadLocal变量的实例。一个线程只能使用get和set改变本线程内该变量的值,不同线程中该变量值互不影响。具体实现中,每个Thread线程对象都由一个ThreadLocal.ThreadLocalMap,其键为ThreadLocal.ThreadLocalHashCode,值就是本地线程变量,各个线程的Map是独立的。

锁优化

自旋锁

阻塞线程比较耗时,是因为挂起和恢复线程都需要转换内核态,而锁定共享数据往往只持续很短的时间。因此有时只需要让线程执行一个忙循环(自旋)等待,但是不放弃处理器执行,就可以获取锁。前提是等待时间不能太长,自适应自旋锁可以调整自旋的时间。

锁消除

如果代码上要求同步,但是经过逃逸分析发现不可能存在共享数据竞争,因而可以将锁进行消除。有些代码的同步可能不是人为加入的,而是源码自带的。

锁粗化

如果一系列加锁和解锁是对同一个对象连续进行的,就可以将同步范围扩大到整个序列的外部,这样就可以进行一次加锁和解锁了。

轻量级锁

认为大部分锁在同步时间内是不存在竞争的。通过利用对象头信息Mark Word和CAS操作判断对象是否加锁,如果没有,则直接进入同步块执行,这个判断过程就是轻量级锁;否则说明的这个加锁的对象有竞争,轻量级锁就要膨胀为重量级锁,也就是使用互斥量的一般锁。 解锁也要使用CAS操作,将原来的Mark Word的记录替换回来,如果替换成功说明在此期间没有线程尝试过获取该锁,从而解锁完成;如果失败,则一定有线程尝试过获取该锁,所以在解锁完成后还要唤醒等锁的线程。

偏向锁

在轻量级锁的基础上,如果没有竞争,线程将CAS操作也取消,且这个偏向锁会偏向第一个获得它的线程。如果执行过程中该锁没有被其他线程获取,则持有偏向锁的线程永远不用同步。但是一旦有线程尝试获取该锁时,偏向模式被撤销,将锁对象恢复为未锁定或者轻量级锁。