您的位置 首页 java

Java并发编程演进史

并发编程一直是一个热门,通过 多线程 实现并发大大提高了程序的处理效率和资源的利用率。但随着而来也出现了一些问题,首先多线程并发运行给执行过程带来了很多不确定性,因为只有同一个 线程 内部代码的执行顺序是固定的,而不同线程之间的代码执行顺序无法确定。另一个重要的问题是多个线程(或进程)要执行同一个特定的不可重入的程序代码块,这就可能出现竞态问题。

随着而来应用而生了发控制同步技术,这其中 synchronized final , ReentrantLock , Atomic 等技术很好的解决了多线程条件下资源冲突的问题。本文我们主要介绍下常用的锁及锁的演进过程,帮助大家更清晰的理解相关知识。

JDK1.4以前并发处理方式

在JDK1.4及之前的版本,处理多线程并发主要提供了两种技术:

  • synchronized
  • volatile

synchronized:

synchronized Java 中一直是元老级别的存在,它是实现锁的关键。 synchronized 修饰的方法或代码块相当于并发中的临界区,即在同一时刻jvm只允许一个线程进入执行。

 // 对方法区加锁
public synchronized void add(int n) {
    count += n;
} 

//对代码块加锁
public void add(int n) {
    synchronized(this) {
        count += n;
    }
}  

synchronized 在JDK1.5之前一直被称为重量级锁,底层是使用操作系统的mutex lock(互斥锁)实现的,每个对象都对应于一个可称为” 互斥锁” 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。

互斥锁很好的解决了多线程对资源竞争的问题,因为在任何给定时间只有一个线程获取锁,但这种操作的副作用也是显而易见的:

  • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。
  • 如果一个线程获得了锁并且在此过程中它被抢占,则另一个线程可能无法移动,这就造成了 死锁 。死锁往往是致命的他必须人为的去杀死一个多多个关联进程才能解决,单这种情况往往会照成数据的丢失或数据的不一致。

volatile:

volatile 在面试中也是一个高频的面试题,面试者往往被问及”你对 volatile 的理解?“,相信很多人对此有了深入的了解,这里我总结如下方面:

  • 相较于 synchronized 来说 volatile 是轻量级的锁,某些场景下性能上是优于 synchronized
  • volatile 主要作用是保证可见性以及有序性但是并不能保证原子性,所以是无法替代 synchronized

下面我们简要介绍下volatile的工作原理,我们先看下面这张图:

 public class Test {

    private volatile int data = 0;

    // 线程1读取和修改data变量值

    // 线程2读取data变量值

}  

如上图java内存模型中,每个线程有自己的工作内存,同时还有一个共享的主内存。

  • 如果两个线程,他们的代码里都需要读取data这个变量的值,那么他们都会从主内存里加载data变量的值到自己的工作内存,然后才可以使用那个值。
  • 一旦data变量定义的时候前面加了volatile来修饰的话,那么线程1只要修改data变量的值,就会在修改完自己本地工作内存的data变量值之后,强制将这个data变量最新的值刷回主内存。
  • 如果此时别的线程的工作内存中有这个data变量的本地缓存,也就是一个变量副本的话,那么会强制让其他线程的工作内存中的data变量缓存直接失效过期。

上面的模式看起来还不错,但JDK1.5之前,它是一个备受争议的关键字,因为在程序中使用它往往会导致出人意料的结果,主要有如下几方面的缺陷:

  • 只保证声明为volatile关键字的变量,但是不会管其他变量。
  • 不保证代码顺序,这点是致命的,直接的结果就是同一段代码可能导致不通的结果。

总结一下:

上面我们主要介绍了,对于并发编程的资源竞态Java的两种处理方式: synchronized volatile ,但在Jdk1.5前,他们都存在一定的缺陷:

  • synchronized 是重量级的锁,会造成资源的过多消耗和频繁的死锁问题。
  • volatile 在JDK1.4中存在着设计上的缺陷,线程间不可见及不保证代码顺序。

JDK1.5对并发处理的优化

上面我们介绍了在JDK1.5前 synchronized volatile 工作原理及设计上的缺陷,那在JDK1.5中做了哪些优化及添加了哪些新的功能呢?

JDK5是Java发展的一个重要版本,提供了很多技术,如泛型 Generic、枚举类型 Enumeration、可变参数varargs、注解 Annotations等等。在JDK1.5版本中,也提供了对并发编程极为重要的一个包:java.util.concurrent(并发包)

JDK1.5之后的synchronized

从JDK1.5开始。synchronized锁的实现发生了很大的变化,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”,从而减少锁的竞争所带来的用户态和内核态之间的切换。

下面我们分别介绍下这几种锁:

  • 轻量级锁

“轻量级锁”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

轻量级锁的加锁过程

(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录( lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图所示

(2)拷贝对象头中的Mark Word复制到锁记录中。

(3)拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。

(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图下图。

(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

  • 偏向锁

在大多数情况下,锁总是由同一个线程获取,不存在多线程竞争的情况。这种情况下就出现了“偏向锁”其目标就是在只有一个线程执行同步代码块时能够提高性能。

因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

通过上图我们说明下“偏向锁”的执行过程:

(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。

(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。

(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。

(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

(5)执行同步代码。

JDK1.5之后的volatile

JDK1.5实现了一个规范”JSR133″——Java Memory Model and Thread Specification Revision。JSR133内容很多,其中一个要点是引入了一个名词” Happens-Before “。这实际上是两条规则:

  • 编译器可以生成与原始代码顺序不同的代码,但是乱序不可以跨越对声明为 voltaile 的变量读写。即在volatile变量访问前的代码不可以乱序到访问后;访问后的代码不可以乱序到访问前。volatile保证编译不会肆意乱序
  • 如果线程1写入一个声明为 volatile 共享变量,线程2读取这个共享变量。那么在线程1写入共享变量之前,所有对线程1可见的变量,在线程2读取共享变量之后的代码均可见

我们看一个例子:

 // volatile示例代码
class Count {
    public static volatile int i = 0;
}

class CountRunnable implements Runnable {
    Count count = null;

    public CountRunnable(Count count) {
        this.count = count;
    }

    public synchronized void add() {
        for (int j = 0; j < 100; j++) {
            count.i++;
        }
    }

    @Override
    public void run() {
        add();
    }
}

class TestVolatile {
    public static void main(String[] args) throws InterruptedException {
            Count count = new Count();
            List<Thread> trd = new ArrayList<Thread>();
            for (int threadNum = 0; threadNum < 1000; threadNum++) {
                Thread t = new Thread(new CountRunnable(count));
                trd.add(t);
                t.start();
            }
            for (int i = 0; i < 1000; i++) {
                trd.get(i).join();
            }
            System.out.println(count.i); //(1)
    }
}  

上面的代码中创建了100个线程,然后在每个线程中对变量 i 进行了1000次的自增运算,那么也就意味着,如果这段代码可以正确的并发运行,每次运行最后在代码(1)处应该输出100000。

但是多次运行你会发现每次输出的结果并不是我们预期的那样,有时会出现小于100000。也就是说每次运行的结果是不固定的不一样的,这是为什么呢? 因为通过上面 volatile 关键字的语义我们知道被该关键字修饰的变量对所有的线程是可见的啊,那怎么会出现这种情况呢?

我们知道每一个线程都有自己的私有内存,而线程之间的通信是通过主存来实现的, volatile 在这里保证多线程的可见性的意思是说:如果一个线程修改了被 volatile 关键字修饰的变量,会立马刷新到主内存中,其他需要使用这个变量的线程不在从自己的私有内存中获取了,而是直接从主内存中获取。虽然 volatile 关键字保证了变量对所有线程的可见性, 但是java代码中的运算操作并非原子操作 。这就要引出我们下面要说的 Atomic 操作了。

JDK1.5之后的原子(Atomic)类型

原子(Atomic)类型:如AtomicInteger、AtomicReference等,保证变量的原子性和可见性

同样的上述volatile示例代码,我们修改一处就可以实现原子性操作,实现每次输出结果都是100000

 class Count {
    //public static volatile int i = 0;

    public AtomicInteger i = new AtomicInteger();
}

class CountRunnable implements Runnable {
    Count count = null;

    public CountRunnable(Count count) {
        this.count = count;
    }

    public synchronized void add() {
        for (int j = 0; j < 100; j++) {
            count.i.getAndAdd(1);
            //count.i++;
        }
    }

    @Override
    public void run() {
        add();
    }
}

class TestAtomic {
    public static void main(String[] args) throws InterruptedException {
        Count count = new Count();
        List<Thread> trd = new ArrayList<Thread>();
        for (int threadNum = 0; threadNum < 1000; threadNum++) {
            Thread t = new Thread(new CountRunnable(count));
            trd.add(t);
            t.start();
        }
        for (int i = 0; i < 1000; i++) {
            trd.get(i).join();
        }
        System.out.println(count.i);

    }
}  

JDK1.5之后的Lock类型

java.util.concurrent.locks 包提供的 ReentrantLock 用于替代 synchronized 加锁

 public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count;

    public void add(int n) {
        lock.lock();
        try {
            count += n;
        } finally {
            lock.unlock();
        }
    }
}  

相较于 synchronized 两者对比如下:

  • ReentrantLock 显示获得、释放锁, synchronized 隐式获得释放锁
  • ReentrantLock 可响应中断、可轮回, synchronized 是不可以响应中断的,为处理锁的不可用性提供了更高的灵活性
  • ReentrantLock API 级别的, synchronized JVM 级别的
  • ReentrantLock 可以实现公平锁
  • ReentrantLock 通过 Condition 可以绑定多个条件
  • 底层实现不一样, synchronized 是同步阻塞,使用的是悲观并发策略, lock 是同步非阻塞,采用的是乐观并发策略

JDK1.8对并发处理优化

JDK1.8主要做了如下优化:

  • 加法器(Adder)和累加器(Accumulator):原子类型的扩充与优化,主要有:LongAdder、LongAccumulator、DoubleAdder和DoubleAccumulator,比AtomicLong和AtomicDouble性能更优。
  • CompletableFuture:JDK5中Future的增强版。
  • StampedLock:JDK5中ReadWriteLock的改进版。

欢迎关注公众号: 编码是个技术活

文章来源:智云一二三科技

文章标题:Java并发编程演进史

文章地址:https://www.zhihuclub.com/183493.shtml

关于作者: 智云科技

热门文章

发表回复

您的电子邮箱地址不会被公开。

网站地图