前言
不小心就鸽了几天没有更新了,这个星期回家咯。在学校的日子要努力一点才行!
只有光头才能变强
回顾前面:
本文章的知识主要参考《Java并发编程实战》这本书的前4章,这本书的前4章都是讲解并发的基础的。要是能好好理解这些基础,那么我们往后的学习就会事半功倍。
当然了,《Java并发编程实战》可以说是非常经典的一本书。我是未能完全理解的,在这也仅仅是抛砖引玉。想要更加全面地理解我下面所说的知识点,可以去阅读一下这本书,总的来说还是不错的。
首先来预览一下《Java并发编程实战》前4章的目录究竟在讲什么吧:
第1章 简介
- 1.1 并发简史
- 1.2 线程的优势
- 1.2.1 发挥多处理器的强大能力
- 1.2.2 建模的简单性
- 1.2.3 异步事件的简化处理
- 1.2.4 响应更灵敏的用户界面
- 1.3 线程带来的风险
- 1.3.1 安全性问题
- 1.3.2 活跃性问题
- 1.3.3 性能问题
- 1.4 线程无处不在
ps:这一部分我就不讲了,主要是引出我们接下来的知识点,有兴趣的同学可翻看原书~
第2章 线程安全性
- 2.1 什么是线程安全性
- 2.2 原子性
- 2.2.1 竞态条件
- 2.2.2 示例:延迟初始化中的竞态条件
- 2.2.3 复合操作
- 2.3 加锁机制
- 2.3.1 内置锁
- 2.3.2 重入
- 2.4 用锁来保护状态
- 2.5 活跃性与性能
第3章 对象的共享
- 3.1 可见性
- 3.1.1 失效数据
- 3.1.2 非原子的64位操作
- 3.1.3 加锁与可见性
- 3.1.4 Volatile变量
- 3.2 发布与逸出
- 3.3 线程封闭
- 3.3.1 Ad-hoc线程封闭
- 3.3.2 栈封闭
- 3.3.3 ThreadLocal类
- 3.4 不变性
- 3.4.1 Final域
- 3.4.2 示例:使用Volatile类型来发布不可变对象
- 3.5 安全发布
- 3.5.1 不正确的发布:正确的对象被破坏
- 3.5.2 不可变对象与初始化安全性
- 3.5.3 安全发布的常用模式
- 3.5.4 事实不可变对象
- 3.5.5 可变对象
- 3.5.6 安全地共享对象
第4章 对象的组合
- 4.1 设计线程安全的类
- 4.1.1 收集同步需求
- 4.1.2 依赖状态的操作
- 4.1.3 状态的所有权
- 4.2 实例封闭
- 4.2.1 Java监视器模式
- 4.2.2 示例:车辆追踪
- 4.3 线程安全性的委托
- 4.3.1 示例:基于委托的车辆追踪器
- 4.3.2 独立的状态变量
- 4.3.3 当委托失效时
- 4.3.4 发布底层的状态变量
- 4.3.5 示例:发布状态的车辆追踪器
- 4.4 在现有的线程安全类中添加功能
- 4.4.1 客户端加锁机制
- 4.4.2 组合
- 4.5 将同步策略文档化
那么接下来我们就开始吧~
一、使用多线程遇到的问题
1.1线程安全问题
在前面的文章中已经讲解了线程【】,多线程主要是为了提高我们应用程序的使用率。但同时,这会给我们带来很多安全问题!
如果我们在单线程中以“顺序”(串行-->独占)的方式执行代码是没有任何问题的。但是到了多线程的环境下(并行),如果没有设计和控制得好,就会给我们带来很多意想不到的状况,也就是线程安全性问题
因为在多线程的环境下,线程是交替执行的,一般他们会使用多个线程执行相同的代码。如果在此相同的代码里边有着共享的变量,或者一些组合操作,我们想要的正确结果就很容易出现了问题
简单举个例子:
- 下面的程序在单线程中跑起来,是没有问题的。
public class UnsafeCountingServlet extends GenericServlet implements Servlet { private long count = 0; public long getCount() { return count; } public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { ++count; // To something else... }}
但是在多线程环境下跑起来,它的count值计算就不对了!
首先,它共享了count这个变量,其次来说++count;
这是一个组合的操作(注意,它并非是原子性)
-
++count
实际上的操作是这样子的:- 读取count值
- 将值+1
- 将计算结果写入count
于是多线程执行的时候很可能就会有这样的情况:
- 当线程A读取到count的值是8的时候,同时线程B也进去这个方法上了,也是读取到count的值为8
- 它俩都对值进行加1
- 将计算结果写入到count上。但是,写入到count上的结果是9
- 也就是说:两个线程进来了,但是正确的结果是应该返回10,而它返回了9,这是不正常的!
如果说:当多个线程访问某个类的时候,这个类始终能表现出正确的行为,那么这个类就是线程安全的!
有个原则:能使用JDK提供的线程安全机制,就使用JDK的。
当然了,此部分其实是我们学习多线程最重要的环节,这里我就不详细说了。这里只是一个总览,这些知识点在后面的学习中都会遇到~~~
1.3性能问题
使用多线程我们的目的就是为了提高应用程序的使用率,但是如果多线程的代码没有好好设计的话,那未必会提高效率。反而降低了效率,甚至会造成死锁!
就比如说我们的Servlet,一个Servlet对象可以处理多个请求的,Servlet显然是一个天然支持多线程的。
又以下面的例子来说吧:
public class UnsafeCountingServlet extends GenericServlet implements Servlet { private long count = 0; public long getCount() { return count; } public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { ++count; // To something else... }}
从上面我们已经说了,上面这个类是线程不安全的。最简单的方式:如果我们在service方法上加上JDK为我们提供的内置锁synchronized,那么我们就可以实现线程安全了。
public class UnsafeCountingServlet extends GenericServlet implements Servlet { private long count = 0; public long getCount() { return count; } public void synchronized service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { ++count; // To something else... }}
虽然实现了线程安全了,但是这会带来很严重的性能问题:
- 每个请求都得等待上一个请求的service方法处理了以后才可以完成对应的操作
这就导致了:我们完成一个小小的功能,使用了多线程的目的是想要提高效率,但现在没有把握得当,却带来严重的性能问题!
在使用多线程的时候:更严重的时候还有死锁(程序就卡住不动了)。
这些都是我们接下来要学习的地方:学习使用哪种同步机制来实现线程安全,并且性能是提高了而不是降低了~
二、对象的发布与逸出
书上是这样定义发布和逸出的:
发布(publish) 使对象能够在当前作用域之外的代码中使用逸出(escape) 当某个不应该发布的对象被发布了
常见逸出的有下面几种方式:
- 静态域逸出
- public修饰的get方法
- 方法参数传递
- 隐式的this
静态域逸出:
public修饰get方法:
方法参数传递我就不再演示了,因为把对象传递过去给另外的方法,已经是逸出了~
下面来看看该书给出this逸出的例子:
逸出就是本不应该发布对象的地方,把对象发布了。导致我们的数据泄露出去了,这就造成了一个安全隐患!理解起来是不是简单了一丢丢?
2.1安全发布对象
上面谈到了好几种逸出的情况,我们接下来来谈谈如何安全发布对象。
安全发布对象有几种常见的方式:
-
在静态域中直接初始化 :
public static Person = new Person()
;- 静态初始化由JVM在类的初始化阶段就执行了,JVM内部存在着同步机制,致使这种方式我们可以安全发布对象
-
对应的引用保存到volatile或者AtomicReferance引用中
- 保证了该对象的引用的可见性和原子性
-
由final修饰
- 该对象是不可变的,那么线程就一定是安全的,所以是安全发布~
-
由锁来保护
- 发布和使用的时候都需要加锁,这样才保证能够该对象不会逸出
三、解决多线程遇到的问题
从上面我们就可以看到,使用多线程会把我们的系统搞得挺复杂的。是需要我们去处理很多事情,为了防止多线程给我们带来的安全和性能的问题~
下面就来简单总结一下我们需要哪些知识点来解决多线程遇到的问题。
3.1简述解决线程安全性的办法
使用多线程就一定要保证我们的线程是安全的,这是最重要的地方!
在Java中,我们一般会有下面这么几种办法来实现线程安全问题:
- 无状态(没有共享变量)
- 使用final使该引用变量不可变(如果该对象引用也引用了其他的对象,那么无论是发布或者使用时都需要加锁)
- 加锁(内置锁,显示Lock锁)
-
使用JDK为我们提供的类来实现线程安全(此部分的类就很多了)
- 原子性(就比如上面的
count++
操作,可以使用AtomicLong来实现原子性,那么在增加的时候就不会出差错了!) - 容器(ConcurrentHashMap等等...)
- ......
- 原子性(就比如上面的
- ...等等
3.2原子性和可见性
何为原子性?何为可见性?当初我在中已经简单说了一下了。不了解的同学可以进去看看。
3.2.1原子性
在多线程中很多时候都是因为某个操作不是原子性的,使数据混乱出错。如果操作的数据是原子性的,那么就可以很大程度上避免了线程安全问题了!
-
count++
,先读取,后自增,再赋值。如果该操作是原子性的,那么就可以说线程安全了(因为没有中间的三部环节,一步到位【原子性】~
原子性就是执行某一个操作是不可分割的,
- 比如上面所说的`count++`操作,它就不是一个原子性的操作,它是分成了三个步骤的来实现这个操作的~- **JDK中有atomic包提供给我们实现原子性操作**~
也有人将其做成了表格来分类,我们来看看:
使用这些类相关的操作也可以进他的博客去看看:
3.2.2可见性
对于可见性,Java提供了一个关键字:volatile给我们使用~
- 我们可以简单认为:volatile是一种轻量级的同步机制
volatile经典总结:volatile仅仅用来保证该变量对所有线程的可见性,但不保证原子性
我们将其拆开来解释一下:
-
保证该变量对所有线程的可见性
- 在多线程的环境下:当这个变量修改时,所有的线程都会知道该变量被修改了,也就是所谓的“可见性”
-
不保证原子性
- 修改变量(赋值)实质上是在JVM中分了好几步,而在这几步内(从装载变量到修改),它是不安全的。
使用了volatile修饰的变量保证了三点:
- 一旦你完成写入,任何访问这个字段的线程将会得到最新的值
- 在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
- volatile可以防止重排序(重排序指的就是:程序执行的时候,CPU、编译器可能会对执行顺序做一些调整,导致执行的顺序并不是从上往下的。从而出现了一些意想不到的效果)。而如果声明了volatile,那么CPU、编译器就会知道这个变量是共享的,不会被缓存在寄存器或者其他不可见的地方。
一般来说,volatile大多用于标志位上(判断操作),满足下面的条件才应该使用volatile修饰变量:
- 修改变量时不依赖变量的当前值(因为volatile是不保证原子性的)
- 该变量不会纳入到不变性条件中(该变量是可变的)
- 在访问变量的时候不需要加锁(加锁就没必要使用volatile这种轻量级同步机制了)
参考资料:
3.3线程封闭
在多线程的环境下,只要我们不使用成员变量(不共享数据),那么就不会出现线程安全的问题了。
就用我们熟悉的Servlet来举例子,写了那么多的Servlet,你见过我们说要加锁吗??我们所有的数据都是在方法(栈封闭)上操作的,每个线程都拥有自己的变量,互不干扰!
在方法上操作,只要我们保证不要在栈(方法)上发布对象(每个变量的作用域仅仅停留在当前的方法上),那么我们的线程就是安全的
在线程封闭上还有另一种方法,就是我之前写过的:
使用这个类的API就可以保证每个线程自己独占一个变量。(详情去读上面的文章即可)~
3.4不变性
不可变对象一定线程安全的。
上面我们共享的变量都是可变的,正由于是可变的才会出现线程安全问题。如果该状态是不可变的,那么随便多个线程访问都是没有问题的!
Java提供了final修饰符给我们使用,final的身影我们可能就见得比较多了,但值得说明的是:
- final仅仅是不能修改该变量的引用,但是引用里边的数据是可以改的!
就好像下面这个HashMap,用final修饰了。但是它仅仅保证了该对象引用hashMap变量
所指向是不可变的,但是hashMap内部的数据是可变的,也就是说:可以add,remove等等操作到集合中~~~
- 因此,仅仅只能够说明hashMap是一个不可变的对象引用
final HashMaphashMap = new HashMap<>();
不可变的对象引用在使用的时候还是需要加锁的
- 或者把Person也设计成是一个线程安全的类~
- 因为内部的状态是可变的,不加锁或者Person不是线程安全类,操作都是有危险的!
要想将对象设计成不可变对象,那么要满足下面三个条件:
- 对象创建后状态就不能修改
- 对象所有的域都是final修饰的
- 对象是正确创建的(没有this引用逸出)
String在我们学习的过程中我们就知道它是一个不可变对象,但是它没有遵循第二点(对象所有的域都是final修饰的),因为JVM在内部做了优化的。但是我们如果是要自己设计不可变对象,是需要满足三个条件的。
3.5线程安全性委托
很多时候我们要实现线程安全未必就需要自己加锁,自己来设计。
我们可以使用JDK给我们提供的对象来完成线程安全的设计:
非常多的"工具类"供我们使用,这些在往后的学习中都会有所介绍的~~这里就不介绍了
四、最后
正确使用多线程能够提高我们应用程序的效率,同时给我们会带来非常多的问题,这些都是我们在使用多线程之前需要注意的地方。
无论是不变性、可见性、原子性、线程封闭、委托这些都是实现线程安全的一种手段。要合理地使用这些手段,我们的程序才可以更加健壮!
可以发现的是,上面在很多的地方说到了:锁。但我没有介绍它,因为我打算留在下一篇来写,敬请期待~~~
书上前4章花了65页来讲解,而我只用了一篇文章来概括,这是远远不够的,想要继续深入的同学可以去阅读书籍~
之前在学习操作系统的时候根据《计算机操作系统-汤小丹》这本书也做了一点点笔记,都是比较浅显的知识点。或许对大家有帮助
参考资料:
- 《Java核心技术卷一》
- 《Java并发编程实战》
- 《计算机操作系统-汤小丹》
如果文章有错的地方欢迎指正,大家互相交流。习惯在微信看技术文章,想要获取更多的Java资源的同学,可以 关注微信公众号:Java3y。为了大家方便,刚新建了一下 qq群:742919422,大家也可以去交流交流。谢谢支持了!希望能多介绍给其他有需要的朋友
文章的目录导航: