黑马程序员技术交流社区

标题: 【西安校区】 java并发编程的艺术 [打印本页]

作者: 逆风TO    时间: 2019-9-4 09:47
标题: 【西安校区】 java并发编程的艺术
文章目录
1. JAVA并发机制的底层实现原理
1. 锁的状态
2. 原子操作的实现原理
2. java内存模型
1. 基础
2. 重排序
4. JAVA并发编程基础
1. Synchronized底层指令
2. Thread.join
5. JAVA中的锁
1. AbstractQueuedSynchronized(AQS, 同步器)
2. LockSupport
3. Condition
6. JAVA并发容器和框架
1. ConcurrentHashMap
2. ConcurrentLinkedQueue
1. JAVA并发机制的底层实现原理
1. 锁的状态
since jdk1.6, 级别从低到高有:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。
锁的状态只能升级,不能降级。目的在于为了提高获得锁和释放锁的效率。
2. 原子操作的实现原理
使用总线锁保证原子性。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将请求阻塞住,那么该处理器可以独享内存。
通过缓存锁定来保证原子性。总线锁是锁定CPU和内存之间的通信,在这期间,其他处理器不能操作其他内存地址的数据,因此,总线锁的开销较大。 而缓存锁则是保证在同一时刻,只需要保证对某个内存地址操作是原子性即可。
2. java内存模型
1. 基础
在java中,所有的实例域、静态域和数据元素都是存储在堆内存中,堆内存在线程之间共享;局部变量、方法定义参数和异常处理参数不会在线程之间共享,不存在线程可见性问题。
重排序:
编译器优化的重排序: 编译器在不改变单线程程序语义的前提下,可以重排序。
指令级并行的重排序:若不存在数据之间的依赖性,处理器可以改变语句对应的机器指令的执行顺序。
内存系统的重排序:由于处理器使用缓存和读写缓存区,使得加载和存储操作看起来是在乱序执行。
从java源码到实际执行的指令序列:按照1->2->3进行重排序。
happens-before原则:
定义:在JMM中若一个执行操作的结果对于另一个操作可见,两个操作之间比逊存在happens-before原则。两个操作可以在同一个线程,也可以在不同的线程中。
原则:
监视器锁规则:对于一个锁的解锁,必须位于对于这个锁加锁之后。
volatile规则:对于一个volatile域的写,然后才能对这个volatile域的读取。
传递性:A 优先于 B, B 优先于C, 那么A必须优先于C;
2. 重排序
重排序是为了优化程序性能而使用的一种手段。
编译器和处理器在进行重排序时,会遵守数据依赖性,不会对于存在数据依赖关系的两个操作进行重排序。但是,只是针对单线程中,多线程或是不同处理器之间的数据依赖性,则不遵循。
as-if-serial语义:不管如何进行重排序,不能改变最终结果。
4. JAVA并发编程基础
1. Synchronized底层指令
对于同步块而言moniorenter和monitorexit指令;而对于方法而言使用的是ACC_SYNCHRONIZED来完成。但是不管使用的是什么指令,目的都是为了获取到对象的监视器,这个获取过程是排他的;没有获取到锁的所有线程都处于Blocked状态;而不是Wating状态。调用了wait方法,才会使线程处于Waiting状态。
2. Thread.join
join若当前线程存活则一直wait;当线程结束时,会调用自身的notifyAll,会通知所有等待在该线程对象上的线程。
通过join,把所有的线程,变为一个队列。
private static void threadJoin() throws InterruptedException {
    Thread pre = Thread.currentThread();

    for (int i = 0; i < 10; i++) {        /**
         * {@link Thread#join()}: 当join的thread停止后, 程序便会接着执行.
         * 此段代码:
         *  1. 通过主线程的join, 不断阻塞后线程的执行; 因为设置的join线程都还在运行.
         *  2. 主线程join第一个线程, 第一个线程join第二个线程, 依次类推, 可以想象所有的线程变成了一个队列, 只能依次出队.
         *  2. 然后, 主线程的代码运行完毕, 主线程停止, 第一个被主线程join的线程接着运行. 依次类推.
         */
        Thread current = new Thread(new Domino(pre, i));
        current.start();
        pre = current;
    }

    TimeUnit.SECONDS.sleep(5);
    System.out.println(Thread.currentThread().getName() + " is terminate!");
}

/**
  * 使用final去修饰属性, 使得只能通过构造函数传入参数; 保证了数据的安全性; 因此, 此类也是一个线程安全的类。
  */
static class Domino implements Runnable {

    private final Thread thread;

    private final Integer no;

    public Domino(Thread thread, Integer no) {        
            this.thread = thread;
        this.no = no;
    }

    @Override
    public void run() {        
            try {
            thread.join();
        } catch (InterruptedException e) {            
                e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + " is terminate!");
    }
}
5. JAVA中的锁
1. AbstractQueuedSynchronized(AQS, 同步器)
定义:用来构建锁或者其它同步组件的基础框架,它使用一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源的获取线程的排队工作。
使用:主要通过继承来实现AQS的抽象方法,保证同步状态;修改状态主要使用的是AQS的三个方法getState(),setState(int newState)和compareAndSet(int expect, int update)来修改状态,因为这三个操作时线程安全的;
锁和AQS的区别:锁是面向开发者,隐藏了实现细节;AQS是锁的实现者,屏蔽了同步状态的管理、线程的排队、等待与唤醒等底层操作。
AQS提供的模板方法分为:独占式获取和释放同步状态(ReentrantLock)、共享式获取和释放(ReadWriteLock)、查询同步队列中的等待线程情况。
2. LockSupport
定义了一组以park开头的方法用来阻塞当前线程,以及unpark方法唤醒一个被阻塞的线程。
3. Condition
类似Object的wait和notify/notifyAll,必须放在synchronized中;使用Lock对象去获取关联的Condition对象;Condition使用是await和signal/signalAll,必须在Lock中;
实现分析:
等待队列:是一个FIFO的队列;里面存放着等待的线程引用。线程调用await,则会释放锁,然后,加入到队列中,等待被唤醒。Condition更新队列是没有使用CAS,是因为,调用await方法的线程,必然是获取了锁的线程,因此,直接使用锁来保证队列数据的安全性。
通知:当某个线程调用signal的时候,会把等待时间最久的线程唤醒,因为等待队列使用的就是FIFO的队列结构。
6. JAVA并发容器和框架
1. ConcurrentHashMap
ConcurrentHashMap出现的原因:
在并发中使用HashMap会导致死循环: 在并发执行put操作时,会引起死循环。因为多线程会导致HashMap的Entry链表形成环形数据结构,导致Entry的next节点永远不为空,就会产生死循环获取Entry。
使用线程安全的HashTable则性能低下:所有的方法都使用synchronized加锁,不管读取数据还是修改数据,都会加锁。
ConcurrentHashMap的锁分段技术有效的提升并发访问率;因为数据使用了多个锁去保证数据的安全性,因此,减小了锁竞争。
ConcurrentHashMap结构
由Segment数据结构和HashEntry数组组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap中是作为锁的角色;HashEntry则用于存储键值对数据。一个Segment里包含着一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护者一个HashEntry数据的元素,当修改HashEntry的数据时,必须获取与它对应的Segment锁。
Segment数组的长度:为了能够通过按位与的散列算法来定位Segment数组的索引,必须保证Segment数组的长度是2的N次方,所以,必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值来作为Segment的数组长度。
定位Segment:ConcurrentHashMap不管是在获取或是存储数据的时候,都会对元素的HashCode进行再次散列(Wang/Jenkins hash);之所以再散列,就是为了减少散列冲突。
ConcurrentHashMap操作
get:整个get操作是不加锁的,除非读到的值是空才会加锁重读。最根本的原因是将要使用的共享变量都定义成volatile的类型,因此,能够保证读取到的数据,总是最新被修改之后的数据;而且,只是读取操作,不需要对原数据进行操作。
put:对共享数据加锁;put首先定位到Segment,然后在Segment里进行插入操作。可能会扩容。
判断是否需要扩容:插入之前,判断当前Segment中的HashEntry数组是否超过容量,若超过了阀值,则扩容。
扩容:
创建一个的数组,把原数组的元素进行再散列,插入到新的数组。
只对当前的这个Segment进行扩容,而不是整个容器进行扩容。
size:统计ConcurrentHashMap的所有元素大小,就必须对所有Segment元素大小进行求和。为了保证多线程下获取到的size是安全的,把put、remove和clean方法锁住。这样会造成性能的低效。因此,ConcurrentHashMap采用的是:先尝试2次不锁住Segment统计大小,若是两次的大小不一致,则锁住Segment得到size。而ConcurrentHashMap使用modCount来判断容器是否发生改变,每进行一次put/remove/clean,modCount就会加1。
2. ConcurrentLinkedQueue
实现线程安全的队列,可以采用阻塞方式的一个锁(出队和入队都采用一个锁)或两个锁(出队和入队采用不同的锁);或者使用不阻塞的CAS算法。而ConcurrentLinkedQueue就是不阻塞的安全队列。
基础知识
是基于链接节点的无界线程安全队列,FIFO对节点排序。
入队操作:1.定位出尾节点。2.使用CAS算法将入队节点设置成尾节点的next节点,若不成功则重试。







欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/) 黑马程序员IT技术论坛 X3.2