读《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 = 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框架,由于本周比较忙,所以还未深入学习,下篇博客将会使用Fork-Join框架写一些demo帮助理解。

时间: 2024-10-25 06:43:31

读《Java并发编程的艺术》(二)的相关文章

第8周读书笔记-读《编程珠玑》有感

读<编程珠玑>有感 <编程珠玑>(后文简称<珠玑>)在序章中就开宗明义地提出了两个问题:一个是如何对实际问题进行抽象,找出问题的独特性质.二是一个富有意思的小题目:"如何在1MB内存内对0~10^7内若干元素组成的集合内的整数进行排序(10s内)".一开始我想到的是归并排序,但是书中提出可以利用位图的位向量,不用考虑任何排序算法,只需要遍历两次即可,忽然就有茅塞顿开之感,从这个简单的例子中就可以对一些思想窥见一斑:位图数据结构.简单的设计.时间-空间

编程珠玑之关键字(1)--《c语言深度剖析》整理(转)

一.最快关键字register 关键字regiter请求编译器尽可能的将变量存在CPU的寄存器中.有几点注意的地方. 1.register变量必须是能被CPU寄存器所接受的类型,这通常意味着register变量必须是一个单个的值,并且其长度应小于或等于整型的长度. 但是,有些机器的寄存器也能存放浮点数. 2.register变量可能不存放在内存中,所以不能用取址符运算符“ & ”. 3.只有局部变量和形参可以作为register变量,全局变量不行. 4.静态变量不能定义为register.  总

《编程珠玑》---笔记。浏览此文,一窥此书。

第一章: 磁盘排序:对于一个提出的问题,不要未经思考就直接给出答案.要先深入研究问题,搞清楚这个问题的特点,根据这个特点,可能有更好的解决方案. 比如:文中:最初的需求只是"我如何对磁盘文件排序". 我们首先想到了经典的归并排序. 但,进一步了解到排序的内容是10000000个记录,每条记录都是一个7位整数,且只有1M可用的内存.每条记录不相同. [位示图法,详见我的关于排序的博文] 第二章: 三个问题: 1.给定一个包含32位整数的顺序文件,它至多只能包含40亿个这样的整数,并且整数

【编程珠玑】【第二章】问题A

A题 给定一个最多包含40亿个随机排列的32位整数的顺序文件,找出一个不在文件中一32位整数.有三个问题:(1)在文件中至少存在这样一个数?(2)如果有足够的内存,如何处理?(3)如果内存不足,仅可以用文件来进行处理,如何处理? 答案: (1)32位整数,包括-2146473648~~2146473647,约42亿个整数,32bit可用表示的最大无符号整数约为43亿.可见,一定存在至少一个这样的整数不被包含在40亿个整数中. (2)如果采用位向量思想,通过建立一个大小为 2^32 的bool数组

【编程珠玑】【第二章】编程求解组合问题

组合问题 以下两个题目是等价的: 题目1:输入一个字符串,输出该字符串中字符的所有组合.举个例子,如果输入abc,它的组合有空.a.b.c.ab.ac.bc.abc. 题目2:打印一个集合所有的子集和,比如{a,b,c}的子集和有{a},{b},{c},{a,b},{a,c},{b,c},{a,b,c}以及空集{}. 方法一.递归求解给定集合的全组合n!. 之前我们讨论了如何用递归的思路求字符串的全排列,同样,本题也可以用递归的思路来求字符串的全组合. 1.算法思路: 具有三个元素的集合{a,b

编程珠玑番外篇

1.Plan 9 的八卦 在 Windows 下喜欢用 FTP 的同学抱怨 Linux 下面没有如 LeapFTP 那样的方便的工具. 在苹果下面用惯了 Cyberduck 的同学可能也会抱怨 Linux 下面使用 FTP 和 SFTP 是一件麻烦的事情. 其实一点都不麻烦, 因为在 LINUX 系统上压根就不需要用 FTP. 为什么呢? 因为一行简单的配置之后, 你就可以像使用本机文件一样使用远程的任何文件. 无论是想编辑, 查看还是删除重命名, 都和本机文件一样的用. 这么神奇的功能到底如何

《编程珠玑》笔记:数组循环左移

问题描述:数组元素循环左移,将包含 num_elem 个元素的一维数组 arr[num_elem] 循环左移 rot_dist 位.能否仅使用数十个额外字节的存储空间,在正比于num_elem的时间内完成数组的旋转? 一:Bentley's Juggling Alogrithm 移动变量 arr[0] 到临时变量 tmp,移动 arr[rot_dist] 到 arr[0],arr[2rot_dist] 到 arr[rot_dist],依此类推,直到返回到取 arr[0] 中的元素,此时改为从 t

Linux Shell编程之二选择结构

Shell编程学习之二 一.bash的条件测试 测试方法或者说测试书写: test EXPR [ EXPR ] [[ EXPR ]] 例如:测试变量 User_Name 的之是否为root test $User_Name="root" [ $User_Name == "root" ] [[ $User_Name == "root" ] 根据比较时操作数的类型,测试类型分为: 测试类型 运算符 运算符所代表的意义 示例 整形测试 -gt -lt -

《编程珠玑》第一章

一.题目: 如何在1MB的空间里面对一千万个整数进行排序?并且每个数都小于1千万.实际上这个需要1.25MB的内存空间(这里所说的空间是考虑用位图表示法时,每一位代表一个数,则1千万/(1024*1024*8) 约为1.25MB  ). 1MB总共有838,8608个可用位.所以估计也可以在1MB左右的空间里面进行排序了. 分析: 1)基于磁盘的归并排序(耗时间) 2)每个号码采用32位整数存储的话,1MB大约可以存储250 000 个号码,需要读取文件40趟才能把全部整数排序.(耗时间) 3)

《编程珠玑》阅读小记(9) — 取样问题

问题 本章研究的问题是取样问题,也就是程序设计中的随机数,问题描述如下: 程序的输入包含两个整数m和n,其中 m < n:输出是0~n-1范围内m个随机整数的有序列表,不允许重复.从概率的角度看,我们希望没有重复的有序选择,其中每个选择出现的概率相等. 条件假设: 我们假设有一个能返回很大的随机整数(远远大于m 和 n )的函数bigrand(),以及一个能返回i-j范围内均匀选择的随机整数的randint(i,j). 本章关于这个问题提供了三种算法,接下来详细叙述每个算法的程序实现. 算法1