Java 线程安全的实现方法
概述
在软件业发展的初期,程序编写都是以算法为核心的,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,
程序代码则用于处理这些数据,这种思维方式直接站在计算机的角度去抽象问题和解决问题,被称为面向过程的编程思想。与此相对,
面向对象的编程思想则站在现实世界的角度去抽象和解决问题,它把数据和行为都看作对象的一部分,这样可以让程序员能以符合现实世界的思维方式来编写和组织程序。
面向对象的编程思想极大地提升了现代软件开发的效率和软件可以达到的规模,但是现实世界与计算机世界之间不可避免地存在一些差异。
例如,很难想象现实中的对象在一项工作进行期间,会被不停地中断和切换,对象的属性(数据)可能会在中断期间被修改和变脏,
而这些事件在计算机中是再普通不过的。有时候,良好的设计原则不得不向现实做出一些妥协,我们必须保证程序在计算机中正确无误地运行,然后再考虑将代码组织得更好,
让程序运行得更快。对于高效并发来说,首先需要保证并发的正确性,然后在此基础上来实现高效。
线程安全
我们这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提。因为如果根本不存在多线程,又或者一段代码根本不会与其他线程共享数据,
那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是没有什么区别的。
为了更深入地理解线程安全,在这里我们可以不把线程安全当作一个非真即假的二元排他选项来看待,而是按照线程安全的“安全程度”由强至弱来排序,
我们可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变
在Java语言里面(特指JDK 5以后,即Java内存模型被修正之后的Java语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,
都不需要再进行任何线程安全保障措施。对于final变量(基础类型),只要一个不可变的对象被正确地构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都
不会改变,永远都不会看到它在多个线程之中处于不一致的状态。而不可变带来的安全性是最直接、最纯粹的。
绝对线程安全
绝对的线程安全能够完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,
调用者都不需要任何额外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
我们可以通过Java API中一个不是“绝对线程安全”的“线程安全类型”来看看这个语境里的“绝对”究竟是什么意思。
对线程安全的容器java.util.Vector,多线程环境下进行遍历时删除、获取元素操作,也会抛出数组下标越界的错误,这就不符合绝对线程安全。
相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,
但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。在Java语言中,大部分声称线程安全的类都属于这种类型,
例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java语言天生就支持多线程的特性,
线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,
在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,
那就肯定要产生死锁了。也正是这个原因,suspend()和resume()方法都已经被声明废弃了。
线程安全的实现方法
互斥同步
互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段。指在多个线程并发访问共享数据时,
保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,
临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。
synchronized
在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(BlockStructured)的同步语法。
synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。
这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,
那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),
来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值加一,
而在执行monitorexit指令时会将锁计数器的值减一,一旦计数器的值为零,锁随即就被释放了。
从功能上看,根据以上《Java虚拟机规范》对monitorenter和monitorexit的行为描述,可以得出两个关于synchronized的直接推论,这是使用它时需特别注意的:
- 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
- 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。
synchronized同时具有原子性、可见性、一致性。
从执行成本的角度看,synchronized是一个重量级(Heavy-Weight)的操作。Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,
则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。
ReentrantLock
自JDK 5起(实现了JSR 166),Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的
java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步。
重入锁(ReentrantLock)是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可重入的,并且性能不会弱于synchronized。
ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件
- 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。 - 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,
如果要和多个条件关联的时候,就不得不额外添加一个锁;ReentrantLock则无须这样做,多次调用newCondition()方法即可。
非阻塞同步
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。从解决问题的方式上看,互斥同步属于一种悲观的并发策略,
其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),
这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。
随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;
如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,
因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程。
乐观并发策略需要要求操作和冲突检测这两个步骤具备原子性,而这原子性便是靠硬件来实现的,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条计算机指令就能完成。
这类指令常用的有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,CAS)
- 加载链接/条件储存(Load-Linked/Store-Conditional,LL/SC)
因为Java里最终暴露出来的是CAS操作,所以我们以CAS指令为例进行讲解。CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、
旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。
但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
在JDK 5之后,Java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。
HotSpot虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了
Unsafe类在设计上就不是提供给用户程序调用的类(Unsafe::getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它),
在JDK 9之前只有Java类库可以使用CAS,譬如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。
而如果用户程序也有使用CAS操作的需求,那要么就采用反射手段突破Unsafe的访问限制,要么就只能通过Java类库API来间接使用它。
乐观并发示例:
public class AtomicTest {
public static AtomicInteger race = new AtomicInteger(0);
public static void increase() {
race.incrementAndGet();
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
使用AtomicInteger代替int后,程序输出了正确的结果,这一切都要归功于incrementAndGet()方法的原子性。它的实现其实非常简单,如下所示:
/**
* Atomically increment by one the current value.
*
* @return the updated value
*/
public final int incrementAndGet() {
for (; ; ) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next)) {
return next;
}
}
}
incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大一的新值赋值给自己。如果失败了,那说明在执行CAS操作的时候,
旧值已经发生改变,于是再次循环进行下一次操作,直到设置成功为止。
CAS看起来既简单又高效,但显然这种操作无法涵盖互斥同步的所有使用场景,并且存在逻辑漏洞:
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那就能说明它的值没有被其他线程改变过了吗?
这是不能的,因为如果在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过,这个漏洞称为CAS操作的ABA问题。
J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。
但大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效。
无同步方案
要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,
如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。
- 可重入代码(Reentrant Code):这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),
而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。 - 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。
如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,
其中最重要的一种应用实例就是经典Web交互模型中的一个请求对应一个服务器线程(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得
很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。
Java 线程安全的实现方法的更多相关文章
- 停止Java线程,小心interrupt()方法
来源:http://blog.csdn.net/wxwzy738/article/details/8516253 程序是很简易的.然而,在编程人员面前,多线程呈现出了一组新的难题,如果没有被恰当的解决 ...
- 模拟做饭系统(java+线程中的join方法)
(一)项目框架分析 妈妈要去做饭,发现没有酱油,让儿子去买酱油,然后回来做饭. 根据面向对象的思想,有两个对象,妈妈和儿子 主要有两个方法: (一)没有线程控制(即儿子没有买酱油回来妈妈就做好饭了)+ ...
- JAVA线程sleep和wait方法区别
一. sleep 是线程类(Thread)的方法,导致此线程暂停执行指定时间,给执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复,调用sleep 不会释放对象锁.由于没有释放对象锁,所以不能 ...
- Java线程死锁查看分析方法
如何查看是否有Java线程死锁?下面介绍两种方法. 一.Jconsole Jconsole是JDK自带的图形化界面工具,使用JDK给我们的的工具JConsole,可以通过打开cmd然后输 ...
- java线程学习之join方法
join()方法表示一个线程要加入另一个线程,直到被加入的线程执行完毕. 这个概念不好理解的话看面这个例子 public class TestJoin { public static void mai ...
- java线程学习之wait方法
wait 等待方法是让线程进入等待队列,使用方法是 obj.wait(); 这样当前线程就会暂停运行,并且进入obj的等待队列中,称作“线程正在obj上等待”. 如果线程想执行 wait 方法,线程必 ...
- java线程学习之Sleep方法
sleep方法是在线程中常用到的一个方法,它是一个静态方法. sleep(long millis) 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度 ...
- Thread之四:java线程返回结果的方法
两种方式:一种继承Thread类实现:一种通过实现Callable接口. 第一种方法: 因为实现Thread类的run方法自身是没有返回值的,所以不能直接获得线程的执行结果,但是可以通过在run方法里 ...
- java线程学习之yield方法
yield方法是暂停当前正在执行的线程对象,并执行其他线程. 这是一个静态方法,一旦执行,它会使当前线程让出CPU.让出的cpu并不代表当前线程不执行了.当前线程让出CPU后,还会CPU资源的争夺,但 ...
随机推荐
- .net core 常用rsa 加签类
using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math ...
- k8s结合jumpserver做kubectl权限控制 用户在多个namespaces的访问权限 rbac权限控制
圈子太小,做人留一面,日后好相见. 其实这个文章就是用户用jumpserver登录到k8s master节点 然后执行kubectl的时候只有自己namespaces的所有权限. 背景 1,k8s 有 ...
- 剖析虚幻渲染体系(06)- UE5特辑Part 2(Lumen和其它)
目录 6.5 Lumen 6.5.1 Lumen技术特性 6.5.1.1 表面缓存(Surface Cache) 6.5.1.2 屏幕追踪(Screen Tracing) 6.5.1.3 Lumen光 ...
- 第四章 python的turtle库的运用
我们可以尝试用python的自带turtle库绘制一条蟒蛇 首先我们设计一下蟒蛇的基本形状 我们先把这段蟒蛇绘制的实例代码贴出来,各位可以在自己的本地运行一下看看效果,然后我们再继续分析代码: 1 # ...
- WPF教程十三:自定义控件进阶可视化状态与自定义Panel
如果你敲了上一篇的代码,经过上一篇各种问题的蹂躏,我相信自定义控件基础部分其实已经了解的七七八八了.那么我们开始进阶,现在这篇讲的才是真正会用到的核心的东西.简化你的代码.给你提供更多的可能,掌握了这 ...
- PID算法控制直流电机笔记
1.将偏差的比例.积分和微分通过线性组合构成控制量,用这一控制量对被控对象进行控制,这样的控制器称为PID控制器.简单说就是根据系统的误差,利用比例.积分.微分计算出控制量进行控制. 2.为什么需要P ...
- 视图:DBA_TAB_PARTITIONS 分区表视图
Column Datatype 释义 Description TABLE_OWNER VARCHAR2(128) 表的owner Owner of the table TABLE_NAME VARCH ...
- Maven | 把jar包安装到本地仓库
使用的场景 自己写的工具类想安装到本地 从Maven仓库中下载不下来的jar 使用的步骤 首先要保证自己的Maven配置全局环境变量,如果没有配置过maven全局变量,可以按照下面的步骤配置一下: 先 ...
- 关于varnish缓存
目录 缓存的概念 一.varnish缓存 1. 简介 2. 总体结构 2.1 两个主进程 2.1.1 Management进程 2.1.2 Child/Cacher进程 2.2 Varnish的日志收 ...
- ctf杂项之easy_nbt
下载附件查看 除了几个文件之外,没有思路 搜索nbt可知,可以使用nbtexplorer工具 果断下载,然后打开题目下载的目录 crrl+f搜索flag 猜测kflag{Do_u_kN0w_nbt?} ...