首页 > 代码库 > 读《Java并发编程的艺术》(二)

读《Java并发编程的艺术》(二)

上篇博客开始,我们接触了一些有关Java多线程的基本概念。这篇博客开始,我们就正式的进入了Java多线程的实战演练了。实战演练不仅仅是贴代码,也会涉及到相关概念和术语的讲解。

线程的状态

程的状态分为:新生,可运行,运行,阻塞,死亡5个状态。如下图:

技术分享

状态说明

  • 新生(new):线程对象刚创建,但尚未启动。
  • 可运行(Runnable):线程创建完毕,并调用了start()方法,虽然调用了start(),但是并不一定意味着线程会立即执行,还需要CPU的时间调度。线程此时的状态就是可运行状态。
  • 运行:线程等到了CPU的时间调度,此时线程状态转为运行状态。
  • 阻塞(Blocked):线程由于某种原因被阻碍了,但是此时线程还处于可运行状态。调度机制可以简单的跳过它,不给它分配任何CPU时间。

其他状态比较简单,阻塞状态是其中比较有意思的。造成线程阻塞的原因有:

  1. 调用sleep(毫秒数),使线程进入"睡眠"状态。在规定的毫秒数内,线程不会被执行,使用sleep()使线程进入睡眠状态,但是此时并不会放弃所持有的锁,其他线程此时并不能访问被锁住的对象。sleep()可使优先级低的线程得到执行的机会,当然也可以让同优先级和高优先级的线程有执行的机会。
  2. 调用suspend()暂停线程的执行,除非收到resume()消息,否则不会返回"可运行"状态。强烈建议不要使用这种方式。
  3. 用wait()暂停了线程的执行,除非线程收到notify()或者notifyAll()消息,否则不会变成"可运行"状态。这个方法是Object类中的方法。使用wait()方法,不仅让线程休眠,同时还暂时放弃了其所持有的锁,如果需要使线程暂停休眠,可使用interrupt()方法。虽说wait()放弃了锁,但是在恢复的时候,还得把锁还回来。如何还?
    1. 在wait()中设置参数,比如wait(1000),以毫秒为单位,就表明只借出去1秒中,一秒钟之后,自动收回。
    2. 让借用的线程通知该线程,用完就还。这时,该线程马上就收锁。比如:我设了1小时之后收回,其他线程只用了半小时就完了,那怎么办呢?当然用完了就收回了,不用管我设的是多长时间。别的线程如何通知?就是上面说的使用notify()或者notifyAll()。
  4. 调用yield()方法(Thread类中的方法)自动放弃CPU,让给其他线程。值得注意的是,该方法虽然放弃了CPU,但是还会有机会得到执行,甚至马上。yield()只能使同优先级的线程有执行的机会。
  5. 线程正在等候一些IO操作。
  6. 线程试图调用另一个对象的"同步"方法,但那个对象处于锁定状态,暂时无法使用。

下面是一个关于使用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 =http://www.mamicode.com/ e.value;  
68         //判断是否修改已插入节点的value  
69                 if (!onlyIfAbsent || oldValue =http://www.mamicode.com/= null)  
70                     e.value =http://www.mamicode.com/ 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框架,由于本周比较忙,所以还未深入学习,下篇博客将会使用Fork-Join框架写一些demo帮助理解。

读《Java并发编程的艺术》(二)