最近在进一步学习Java并发编程,不言而喻,这部分内容是很重要的。现在就以《并发编程的艺术》一书为主导线,开始新一轮的学习。
Java 并发编程的艺术PDF清晰完整版+源码 http://www.linuxidc.com/Linux/2016-07/133404.htm
进程和线程
进程是一个应用程序在处理机上的一次执行过程,线程是进程的最小基本单位(个人理解)。一个进程可以包含多个线程。
上下文切换
我们都知道,即使是单核处理器也支持多线程,CPU通过时间片分配算法来给每个线程分配时间让线程得以执行,因为时间片非常短,所以在用户角度来讲,会感觉多个线程是在同时执行。那什么是上下文切换呢?举个例子,当线程A执行到某一步时,此时CPU将时间让给了线程B进行执行,那么在进行切换的时候,系统一定要保存此时此刻线程A所执行任务的状态,比如执行到哪里、运行时的参数等,那么当下一次CPU将时间让给线程A进行执行时,才能正确的切换到A,并继续执行下去。所以任务从保存到再加载的过程就是一次上下文切换。
虽然上下文切换可以让我们觉得可以“同时”做很多事,但是上下文切换也是需要系统开销的。在《Java并发编程的艺术》中,作者举例演示了串行和并发执行累加操作,在结果中可以看得出,累加操作不同的次数会对不同的结果,所消耗的时间也有差别的。如果累加操作的次数没有超过百万次,那么串行执行结果消耗的时间会比并行执行的时间要少。所以在有些情况下我们需要尽可能的减少上下文切换的次数,使用的方法有:无锁并发编程,CAS算法,使用最少线程和使用协程。(这里笔者也只知道有这几种方法,至于具体如何使用以及在何种场景下使用还未深入研究)。
volatile与synchronized
volatile
volatile是轻量级的synchronized,它保证了在多处理器开发中,共享变量的可见性,并且volatile不会引起上下文切换和调度。可见性的意思是当一个线程修改了某个变量的值,另外一个线程可以读到这个变量修改后的值,如果一个变量被volatile修饰,那么Java内存模型确保所有线程看到这个变量的值是一致的。
synchronized
Java中每一个对象都可以作为锁,具体表现为:
- 对于普通的同步方法,锁是当前实例对象
- 对于静态的同步方法,锁是当前类的Class对象
- 对于同步方法块,锁是synchronized括号里配置的对象
当一个线程访问同步代码块时,必须要先得到锁,退出或抛出异常时,必须释放锁。对于上述三种情况,表现形式为:
1 /** 2 * 普通同步方法,锁是当前实例对象 3 */ 4 public synchronized void test1(){ 5 //TODO something 6 } 7 8 /** 9 * 静态同步方法,锁是当前类的Class对象 10 */ 11 public static synchronized void test2(){ 12 //TODO something 13 } 14 15 /** 16 * 同步方法块,锁是synchronized括号中的对象,这里是a 17 */ 18 public void test3(Integer a){ 19 synchronized (a){ 20 //TODO something 21 } 22 }
Java内存模型
Java中所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。
Java线程之间的通信由Java内存模型(JMM)控制。JMM定义了线程和主内存的关系:线程之前的共享变量存储在主内存中,每个线程都有一个私有的本地内存(也叫工作内存),本地内存中存储了该线程读写共享变量的副本。本地内存是JMM的抽象概念,不真实存在,包涵了缓存,写缓冲区,寄存器以及其他硬件和编译器优化。Java内存模型结构图:
从上图可以看出,线程A要与线程B进行通信的话,必须要经过两个步骤:
- 线程A把本地内存A中更新过的共享变量刷新要主内存中去,
- 线程B到主内存中获取更新之后的共享变量。
如下图:
重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
数据依赖性
定义:如果两个操作同时访问一个变量,且这两个操作中有一个为写操作。此时这两个操作之间就存在数据依赖性。
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
as-if-serial语义
语义:不管怎么重排序,单线程程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作进行重排序,但是如果操作之间不存在数据依赖关系,那么就有可能被进行重排序。例如:
1 double pi = 3.14 ; //A 2 double r = 1.0 ; //B 3 double area = pi * r *r ; //C
上面代码中,C依赖A,C依赖B,所以编译器不会重排序将C排在A,B之前。但是A,B之间没有依赖,所以可能被进行重排序,最终的执行顺序有两种:
A->B->C;
B->A->C;
这两种执行顺序对最终结果不会造成影响。
因为存在重排序,所以单线程程序不一定按照程序的顺序来执行。
该文主要讲述了一些偏概念的东西,先有一些印象,后续会以代码示例的形式进行全面的复习。
更多详情见请继续阅读下一页的精彩内容: http://www.linuxidc.com/Linux/2017-06/144524p2.htm
我们接触了一些有关Java多线程的基本概念。这篇博客开始,我们就正式的进入了Java多线程的实战演练了。实战演练不仅仅是贴代码,也会涉及到相关概念和术语的讲解。
线程的状态
程的状态分为:新生,可运行,运行,阻塞,死亡5个状态。如下图:
状态说明:
- 新生(new):线程对象刚创建,但尚未启动。
- 可运行(Runnable):线程创建完毕,并调用了start()方法,虽然调用了start(),但是并不一定意味着线程会立即执行,还需要CPU的时间调度。线程此时的状态就是可运行状态。
- 运行:线程等到了CPU的时间调度,此时线程状态转为运行状态。
- 阻塞(Blocked):线程由于某种原因被阻碍了,但是此时线程还处于可运行状态。调度机制可以简单的跳过它,不给它分配任何CPU时间。
其他状态比较简单,阻塞状态是其中比较有意思的。造成线程阻塞的原因有:
- 调用sleep(毫秒数),使线程进入”睡眠”状态。在规定的毫秒数内,线程不会被执行,使用sleep()使线程进入睡眠状态,但是此时并不会放弃所持有的锁,其他线程此时并不能访问被锁住的对象。sleep()可使优先级低的线程得到执行的机会,当然也可以让同优先级和高优先级的线程有执行的机会。
- 调用suspend()暂停线程的执行,除非收到resume()消息,否则不会返回”可运行”状态。强烈建议不要使用这种方式。
- 用wait()暂停了线程的执行,除非线程收到notify()或者notifyAll()消息,否则不会变成”可运行”状态。这个方法是Object类中的方法。使用wait()方法,不仅让线程休眠,同时还暂时放弃了其所持有的锁,如果需要使线程暂停休眠,可使用interrupt()方法。虽说wait()放弃了锁,但是在恢复的时候,还得把锁还回来。如何还?
- 在wait()中设置参数,比如wait(1000),以毫秒为单位,就表明只借出去1秒中,一秒钟之后,自动收回。
- 让借用的线程通知该线程,用完就还。这时,该线程马上就收锁。比如:我设了1小时之后收回,其他线程只用了半小时就完了,那怎么办呢?当然用完了就收回了,不用管我设的是多长时间。别的线程如何通知?就是上面说的使用notify()或者notifyAll()。
- 调用yield()方法(Thread类中的方法)自动放弃CPU,让给其他线程。值得注意的是,该方法虽然放弃了CPU,但是还会有机会得到执行,甚至马上。yield()只能使同优先级的线程有执行的机会。
- 线程正在等候一些IO操作。
- 线程试图调用另一个对象的”同步”方法,但那个对象处于锁定状态,暂时无法使用。
下面是一个关于使用Object类中wait()和notify()方法的例子:
1 /** 2 * @author zhouxuanyu 3 * @date 2017/05/17 4 */ 5 public class ThreadStatus { 6 7 private String flag[] = { "true" }; 8 9 public static void main(String[] args) { 10 System.out.println("main thread start..."); 11 ThreadStatus threadStatus = new ThreadStatus(); 12 WaitThread waitThread = threadStatus.new WaitThread(); 13 NotifyThread notifyThread = threadStatus.new NotifyThread(); 14 waitThread.start(); 15 notifyThread.start(); 16 } 17 18 class NotifyThread extends Thread { 19 20 @Override 21 public void run() { 22 synchronized (flag) { 23 for (int i = 0; i < 5; i++) { 24 try { 25 sleep(1000); 26 } catch (InterruptedException e) { 27 e.printStackTrace(); 28 } 29 System.out.println("NotifyThread.run()---" + i); 30 } 31 flag[0] = "false"; 32 flag.notify(); 33 } 34 } 35 } 36 37 class WaitThread extends Thread { 38 39 @Override 40 public void run() { 41 //使用flag使得线程获得锁 42 synchronized (flag) { 43 while (flag[0] != "false") { 44 System.out.println("WaitThread.run....."); 45 try { 46 flag.wait(); 47 } catch (InterruptedException e) { 48 e.printStackTrace(); 49 } 50 } 51 System.out.println("wait() end..."); 52 } 53 } 54 } 55 }
上面的代码演示了如何使用wait()和notify()方法,代码释义:主线程执行,打印出main thread start…..语句当在main()中调用waitThread.start()之后,线程启动,执行run方法并获得flag的锁,并开始执行同步代码块中的代码,接下来调用wait()方法后,waitThead阻塞并让出flag锁,此时notifyThread获得flag锁,开始执行,每隔1s打印出对应的语句,循环结束后,将flag中的标志置为false并使用notify()唤醒waitThread线程,使得waitThread线程继续执行,打印出wait() end…此时程序结束。控制台打印结果如下:
线程优先级
每个线程都有一个优先级,在线程大量被阻塞时,程序会优先选择优先级较高的线程执行。但是不代表优先级低的线程不被执行,只是机会相对较小罢。getPriority()获取线程的优先级,setPriority()设置线程的优先级。线程默认的优先级为5。
并发容器和框架
hashmap原理
都知道hashmap是线程不安全的。为什么不安全?先看下hashmap的数据结构:
在JDK1.8之前,hashmap采用数组+链表的数据结构,如上���;而在JDK1.8时,其数据结构变为了数据+链表+红黑树。当链表长度超过8时,会自动转换为红黑树(抽时间复习红黑树,快忘记了!),提高了查找效率。当向hashmap中添加元素时,hashmap内部实现会根据key值计算其hashcode,如果hashcode值没有重复,则直接添加到下一个节点。如果hashcode重复了,则会在重复的位置,以链表的方式存储该元素。JDK1.8源码分析:
1 /** 2 * Associates the specified value with the specified key in thismap. 3 * If the map previously contained a mapping for the key, the old 4 * value is replaced. 5 * 6 */ 7 public V put(K key, V value) { 8 return putVal(hash(key), key, value, false, true); 9 } 10 static final int hash(Object key) { 11 int h; 12 //key的值为null时,hash值返回0,对应的table数组中的位置是0 13 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 14 } 15 16 /** 17 * Implements Map.put and related methods 18 * 19 * @param hash hash for key 20 * @param key the key 21 * @param value the value to put 22 * @param onlyIfAbsent if true, don't change existing value 23 * @param evict if false, the table is in creation mode. 24 * @return previous value, or null if none 25 */ 26 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 27 boolean evict) { 28 Node<K,V>[] tab; Node<K,V> p; int n, i; 29 //先将table赋给tab,判断table是否为null或大小为0,若为真,就调用resize()初始化 30 if ((tab = table) == null || (n = tab.length) == 0) 31 n = (tab = resize()).length; 32 //通过i = (n - 1) & hash得到table中的index值,若为null,则直接添加一个newNode 33 if ((p = tab[i = (n - 1) & hash]) == null) 34 tab[i] = newNode(hash, key, value, null); 35 else { 36 //执行到这里,说明发生碰撞,即tab[i]不为空,需要组成单链表或红黑树 37 Node<K,V> e; K k; 38 if (p.hash == hash && 39 ((k = p.key) == key || (key != null && key.equals(k)))) 40 //此时p指的是table[i]中存储的那个Node,如果待插入的节点中hash值和key值在p中已经存在,则将p赋给e 41 e = p; 42 //如果table数组中node类的hash、key的值与将要插入的Node的hash、key不吻合,就需要在这个node节点链表或者树节点中查找。 43 else if (p instanceof TreeNode) 44 //当p属于红黑树结构时,则按照红黑树方式插入 45 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 46 else { 47 //到这里说明碰撞的节点以单链表形式存储,for循环用来使单链表依次向后查找 48 for (int binCount = 0; ; ++binCount) { 49 //将p的下一个节点赋给e,如果为null,创建一个新节点赋给p的下一个节点 50 if ((e = p.next) == null) { 51 p.next = newNode(hash, key, value, null); 52 //如果冲突节点达到8个,调用treeifyBin(tab, hash),这个treeifyBin首先回去判断当前hash表的长度,如果不足64的话,实际上就只进行resize,扩容table,如果已经达到64,那么才会将冲突项存储结构改为红黑树。 53 54 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 55 treeifyBin(tab, hash); 56 break; 57 } 58 //如果有相同的hash和key,则退出循环 59 if (e.hash == hash && 60 ((k = e.key) == key || (key != null && key.equals(k)))) 61 break; 62 p = e;//将p调整为下一个节点 63 } 64 } 65 //若e不为null,表示已经存在与待插入节点hash、key相同的节点,hashmap后插入的key值对应的value会覆盖以前相同key值对应的value值,就是下面这块代码实现的 66 if (e != null) { // existing mapping for key 67 V oldValue = e.value; 68 //判断是否修改已插入节点的value 69 if (!onlyIfAbsent || oldValue == null) 70 e.value = value; 71 afterNodeAccess(e); 72 return oldValue; 73 } 74 } 75 ++modCount;//插入新节点后,hashmap的结构调整次数+1 76 if (++size > threshold) 77 resize();//HashMap中节点数+1,如果大于threshold,那么要进行一次扩容 78 afterNodeInsertion(evict); 79 return null; 80 }
hashmap不安全的原因:上面分析了JDK源码,知道了put方法不是同步的,如果多个线程,在某一时刻同时操作HashMap并执行put操作,而有大于两个key的hash值相同,这个时候需要解决碰撞冲突,而解决冲突的办法采用拉链法解决碰撞冲突,对于链表的结构在这里不再赘述,暂且不讨论是从链表头部插入还是从尾部插入,这个时候两个线程如果恰好都取到了对应位置的头结点e1,而最终的结果可想而知,这两个数据中势必会有一个会丢失。同理,hashmap扩容的方法也如此,当多个线程同时检测到总数量超过门限值的时候就会同时调用resize操作,各自生成新的数组并rehash后赋给该map底层的数组table,结果最终只有最后一个线程生成的新数组被赋给table变量,其他线程的均会丢失。而且当某些线程已经完成赋值而其他线程刚开始的时候,就会用已经被赋值的table作为原始数组,这样也会有问题。
hashtable原理
HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。
ConcurrentHashMap登场
ConcurrentHashMap锁分段技术:HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。以下是ConcurrentHashMap的数据结构:
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的��色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素每Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。
Fork/Join框架
什么是Fork/Join框架?
Fork/Join框架是Java 7提供的一个用于并行执行任务的框架,是**一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。**Fork就是把一个大任务切分为若干子任务并行的执行,Join就是合并这些子任务的执行结果,最后得到这个大任务的结果。比如计算1+2+…+10000,可以分割成10个子任务,每个子任务分别对1000个数进行求和,最终汇总这10个子任务的结果。如图:
工作窃取算法
工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。假如我们需要做一个比较大的任务,可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。比如A线程负责处理A队列里的任务。但是,有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。如下图:
该篇文章主要记录一些关于线程中锁机制的基础,以及简单分析了一下HashMap,HashTable以及ConcurrentHashMap的相关原理,文章最后简单的涉及了一下Fork-Join框架。
本文永久更新链接地址:http://www.linuxidc.com/Linux/2017-06/144524.htm