Java 多线程:什么是线程安全性
线程安全性
什么是线程安全性
《Java Concurrency In Practice》一书的作者 Brian Goetz 是这样描述“线程安全”的:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。
在这定义中,最核心的概念是“正确性”。
在计算机世界中,在一段程序工作进行期间,会被不停的中断和切换,对象的属性(数据)可能会在中断期间被修改和变脏。
在 Java 语言中,线程安全性的问题限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度来看,程序是串行执行还是多线程执行对它来说都是完全没有区别的。
如果每个线程中对共享数据(如全局变量、静态变量)只有读操作,而无写操作,一般来说这种共享数据是线程安全的,而如果存在多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
Java 中的线程安全
Brian Goetz 曾发表过一篇论文,他并没有将线程安全当做一个非真即假的概念,而是按照线程安全的“安全程度”由强至弱来排序,来将 java 语言中的各种操作共享的数据分为以下 5 类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
1.不可变
在 Java 语言中(特指 JDK 1.5 以后,即 Java 内存模型被修正之后的 Java 语言),不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采取任何的线程安全保障措施。
在 Java 中,如果共享数据的数据类型不同,保证其不可变的方式也有所不同。
共享数据是基本数据类型:这种情况只需要在定义时使用 final 关键字修饰它就可以保证它是不可变的。
共享数据是一个对象:这种情况需要保证对象的行为不会对其状态产生影响。保证对象行为不会影响自己状态的途径有很多种:
比如 String 对象,当我们调用 String 对象的 subString()、replace()等方法时都不会影响它原来的值,只会返回一个新构造的字符串对象。又或者我们可以直接将对象中所有的变量都声明为 final。
2.绝对线程安全
绝对线程安全即是完全满足 Brian Goetz 对线程安全的定义,这是个很严格的定义:一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”。
3.相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,在调用时不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证正确性。Java 语言中的大部分线程安全类都属于这种类型,如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装集合等。
4. 线程兼容
线程兼容指的是对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们通常所说的一个类不是线程安全的,绝大多数时候指的是这种情况。Java API中的大部分类都是属于线程兼容的。比如集合类 ArrayList 和 HashMap 等。
5. 线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。
非线程安全的影响
全局变量的非线程安全
在了解了什么是线程安全之后,我们来看一下在多线程环境下,对非线程安全的共享数据进行操作,会导致什么样的问题。
下面用经典的 Java 多线程模拟卖火车票的问题来进行说明:
public class TicketTest {
public static void main(String[] args) {
TicketSaleRunnable runnable = new TicketSaleRunnable();
Thread t1 = new Thread(runnable, "1号窗口");
Thread t2 = new Thread(runnable, "2号窗口");
Thread t3 = new Thread(runnable, "3号窗口");
t1.start();
t2.start();
t3.start();
}
}
class TicketSaleRunnable implements Runnable {
private int tickets = 10; //总票数10张
public void run() {
while(true) {
if(tickets > 0) {
tickets--;
Thread.yield(); //让出线程,增加出错几率
System.out.println(
Thread.currentThread().getName() + ",剩余票数:" + tickets);
}else {
break;
}
}
}
}
输出结果:
1号窗口,剩余票数:9
3号窗口,剩余票数:7
2号窗口,剩余票数:7
1号窗口,剩余票数:6
2号窗口,剩余票数:4
3号窗口,剩余票数:4
1号窗口,剩余票数:3
3号窗口,剩余票数:1
2号窗口,剩余票数:1
1号窗口,剩余票数:0
可以看到当多个线程同时访问余票(全局变量)时,出现了线程不安全的问题,在不同的线程中输出了重复的结果。
下面我们再通过 ArrayList 和 Vector 来进一步分析一下非线程安全所带来的问题,以及产生的原因。
ArrayList 和 Vector 的线程安全性
不安全的 ArrayList
我们经常见到这样的面试题“ArrayList 和 Vector 的区别,HashMap 和 HashTable 的区别,StringBuilder 和 StringBuffer 的区别?”
答案在上文也有所提及:
ArrayList 是非线程安全的,Vector 是线程安全的;
HashMap 是非线程安全的,HashTable 是线程安全的;
StringBuilder 是非线程安全的,StringBuffer 是线程安全的。
下面通过一个示例来展示一下 ArrayList 非线程安全问题:
public class UnsafeTest {
public static void main(String[] args) throws InterruptedException {
final List<Integer> list = new ArrayList<Integer>();
new Thread(new Runnable(){
public void run() {
for(int i = 0; i < 100; i++) {
list.add(i);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
public void run() {
for(int i = 100; i < 200; i++){
list.add(i);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
Thread.sleep(10000);
// 打印所有结果
for (int i = 0; i < list.size(); i++) {
System.out.println("第" + (i) + "号元素为:" + list.get(i));
}
}
}
运行结果:
Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: 109
at java.util.ArrayList.add(Unknown Source)
at selfprivate.UnsafeTest$2.run(UnsafeTest.java:32)
at java.lang.Thread.run(Unknown Source)
即便是我们多尝试几次,使得程序运行成功结束不抛出异常:
第0号元素为:0
第1号元素为:100
第2号元素为:1
···
第8号元素为:106
第9号元素为:6
第10号元素为:null
第11号元素为:107
第12号元素为:108
···
第185号元素为:197
第186号元素为:97
第187号元素为:98
第188号元素为:198
第189号元素为:199
第190号元素为:99
也经常会发现某些位置出现了 null 值的情况,并且 ArrayList 最终的 size 是小于 200 的。
从运行的结果来看,ArrayList 的确是非线程安全的,我们结合 ArrayList 的源码一起分析一下它的问题主要出在哪里:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//默认集合容量大小
private static final int DEFAULT_CAPACITY = 10;
//ArrayList内部维护的是一个数组来保存元素
transient Object[] elementData;
//elementData所存储的元素个数
private int size;
...
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 判断内部数组的容量是否足够,是否需要扩容
elementData[size++] = e; //将元素保存到数组中,并将 size 自增1
return true;
}
}
1. 多个线程同时进行 add 操作可能会导致抛出数组越界 ArrayIndexOutOfBoundsException 的异常。
当数组总容量为 10,且当前已保存了 9 个元素(即size=9)时,线程A 进入 add 方法,并调用ensureCapacityInternal方法判断了容量够用,不需要扩容。随后立即执行线程B 的add 方法开,也调用了ensureCapacityInternal判断了此时容量够用,不需要扩容,接着执行线程 A 的elementData[size++] = e 操作,size 变为 10,线程 B 也开始执行这个赋值操作,而 elementData[]数组的最大下标为9,则调用 elemenmt[10] = e,则就抛出了数组越界异常了。
2. ArrayList 集合中某些位置上的值出现了 null 的情况
3. 一个线程的值会覆盖掉另一个线程添加的值
这是因为赋值操作 element[size++] = e 并不是一个原子操作,它可以看成这样两步:
elementData[size] = e;
size = size + 1; //注意这一步也不是原子操作
当线程 A 执行了 elementData[size] = e 之后,即开始执行线程 B 的 elementData[size] = e 操作,此时这两个线程的 size 值都还没有增加,所以 线程 B 的值覆盖掉了 线程 A 的赋值。接着线程 A 执行 size 增加 1 的操作,线程 B 的 size 也加 1,这就导致了 size 一共增加了两次,这样就空出了一个位置,就导致某一位置的值为 null 的情况。
4. ArrayList 集合实际的 size 比期望的 size 值要小
这是因为源码中的递增操作 size++ 并非是原子操作,实际上它包含了三个独立的操作:读取 size 的值,将值加1,然后将计算结果写入 size。这在多线程环境就很容易导致 size 的计算出错。线程 A 读取了 size,在执行加1之前,线程 B 也读取了 size 的值,这两个线程获取的是同样的 size 值,然后这两个线程各自为 size 增加 1,将值写入 size 中,最终得到的 size 也只增加了一次,而不是两次。
安全的 Vector
现在我们把上面的例子中的 ArrayList
final List<Integer> list = new ArrayList<Integer>();
替换为 Vector
final List<Integer> list = new Vector<Integer>();
再次运行程序,输出结果:
第0号元素为:0
第1号元素为:100
第2号元素为:1
第3号元素为:101
第4号元素为:2
第5号元素为:102
···
第195号元素为:197
第196号元素为:198
第197号元素为:98
第198号元素为:199
第199号元素为:99
没有出现 null 值的情况,size 的值也与期望的一样是 200。
从结果来看 Vector 确实是线程安全的。那么Vector是如何保证线程安全的呢?
通过查看 Vector 的源码,可以看到它的 add 方法多了一个 synchronized 修饰符。
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
在下一篇文章我们将学习一下 synchronized 操作符的作用。
Java 多线程:什么是线程安全性的更多相关文章
- Java多线程编程(3)--线程安全性
一.线程安全性 一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,那么我们就称其是线程安全的.反之,如果一个类在单线程环境下运作 ...
- Java多线程系列--“JUC线程池”06之 Callable和Future
概要 本章介绍线程池中的Callable和Future.Callable 和 Future 简介示例和源码分析(基于JDK1.7.0_40) 转载请注明出处:http://www.cnblogs.co ...
- Java多线程系列--“JUC线程池”02之 线程池原理(一)
概要 在上一章"Java多线程系列--“JUC线程池”01之 线程池架构"中,我们了解了线程池的架构.线程池的实现类是ThreadPoolExecutor类.本章,我们通过分析Th ...
- Java多线程系列--“JUC线程池”03之 线程池原理(二)
概要 在前面一章"Java多线程系列--“JUC线程池”02之 线程池原理(一)"中介绍了线程池的数据结构,本章会通过分析线程池的源码,对线程池进行说明.内容包括:线程池示例参考代 ...
- Java多线程系列--“JUC线程池”04之 线程池原理(三)
转载请注明出处:http://www.cnblogs.com/skywang12345/p/3509960.html 本章介绍线程池的生命周期.在"Java多线程系列--“基础篇”01之 基 ...
- Java多线程系列--“JUC线程池”05之 线程池原理(四)
概要 本章介绍线程池的拒绝策略.内容包括:拒绝策略介绍拒绝策略对比和示例 转载请注明出处:http://www.cnblogs.com/skywang12345/p/3512947.html 拒绝策略 ...
- -1-5 java 多线程 概念 进程 线程区别联系 java创建线程方式 线程组 线程池概念 线程安全 同步 同步代码块 Lock锁 sleep()和wait()方法的区别 为什么wait(),notify(),notifyAll()等方法都定义在Object类中
本文关键词: java 多线程 概念 进程 线程区别联系 java创建线程方式 线程组 线程池概念 线程安全 同步 同步代码块 Lock锁 sleep()和wait()方法的区别 为什么wait( ...
- 转:java多线程CountDownLatch及线程池ThreadPoolExecutor/ExecutorService使用示例
java多线程CountDownLatch及线程池ThreadPoolExecutor/ExecutorService使用示例 1.CountDownLatch:一个同步工具类,它允许一个或多个线程一 ...
- Java多线程——进程和线程
Java多线程——进程和线程 摘要:本文主要解释在Java这门编程语言中,什么是进程,什么是线程,以及二者之间的关系. 部分内容来自以下博客: https://www.cnblogs.com/dolp ...
- Java多线程之守护线程
Java多线程之守护线程 一.前言 Java线程有两类: 用户线程:运行在前台,执行具体的任务,程序的主线程,连接网络的子线程等都是用户线程 守护线程:运行在后台,为其他前台线程服务 特点:一旦所有用 ...
随机推荐
- service程序改为windows窗体展示
首先将exe程序文件进行快捷创建.然后就会生成一个 exe -shortCut 程序,然后进入属性中,并且进行修改引用路径,在路径xx.exe 后面加一个空格和/tt,保存,这样就可以正常运行了. 如 ...
- Markdown: Syntax Text
Markdown: Syntax Text https://daringfireball.net/projects/markdown/syntax.text Markdown: Syntax ==== ...
- asp.net page类
1 page 继承自control类 2 httpServerUtility的transfer方法:请求生命周期将在调用此方法之后终止,将不会触发后续的请求生命周期事件,将直接跳到logReques ...
- Spring源码解析 - springMVC核心代码
一.首先来讲解下springMVC的底层工作流程 1.首先我们重点放在前端控制器(DispatcherServlet) 其类图: 因为从流程图看,用户的请求最先到达就是DispatcherServle ...
- php-amqplib库操作RabbitMQ
RabbitMQ基本原理 首先,建议去大概了解下RabbitMQ(以下简称mq)的基本工作原理,可以参考这篇文章最主要的几个对象如下 对象名称 borker 相当于mq server channe ...
- 常用Linux文件系统
- list列表的使用
Python最常用的数据类型之一,通过列表可以对数据实现最方便的存储.修改等操作 list1 = [1,2,3,4,5,6,7,8,9] #创建列表 z = list([1,2,3,4,5,6,7,8 ...
- shell 中的通配符:
shell 中的通配符: *: 代表 0 个或者多个任意字符 ?: 代表一定有一个的任意字符 []: 代表一定有一个在括号内的字符(非任意字符).例如[abcd]代表一定有一个字符,可能是 abcd ...
- Ubuntu系统---安装 WPS
Ubuntu系统---安装 WPS Ubuntu桌面系统自带了Libreoffice办公软件,但是个人觉得它不符合我们中国人的使用习惯.搜索了Office For Linux,好麻烦,也会出现问题, ...
- StringBuffer常用方法
StringBuffer常用的方法 package com.mangosoft.java.string; /** * 字符串特点:字符串是常量,它们的值在创建之后不能更改. * * 字符串的内容一旦发 ...