并发编程问题的源头-原子性、可见性、有序性
如何理解线程安全
线程的使用带来了非常多的便利,同时,也给我们带来了很多困扰。
当多个线程访问某个对象时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
线程安全问题的本质
- 原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
- 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
- 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
出现线程安全问题的原因
- 线程切换带来的原子性问题
- 缓存导致的可见性问题
- 编译优化带来的有序性问题
解决办法
- JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
- synchronized、volatile、LOCK,可以解决可见性问题
- Happens-Before 规则可以解决有序性问题
多线程的优劣?
多线程的好处:
可以提高 CPU 的利用率。在多线程程序中,一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务
多线程的劣势:
线程也是程序,所以线程需要占用内存,线程越多占用内存也越多;
多线程需要协调和管理,所以需要 CPU 时间跟踪线程;
线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。
同步关键字synchronized
synchronized锁的范围
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。 - 对于同步方法块,锁是Synchonized括号里配置的对象。
synchronized 锁升级的原理是什么?
synchronized 锁升级原理:
在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。
锁的升级的目的:
锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
synchronized 和 Lock 有什么区别?
- 首先synchronized是Java内置关键字,在JVM层面,Lock是个Java类;
- synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
- synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
volatile关键字分析
对于可见性,Java 提供了 volatile 关键字来保证可见性和禁止指令重排。 volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见 java.util.concurrent.atomic 包下的类,比如 AtomicInteger。
volatile实际上是通过内存屏障来防止指令重排序以及禁止cpu高速缓存来解决可见性问题。
什么情况下需要用到volatile
当存在多个线程对同一个共享变量进行修改的时候,需要增加volatile,保证数据修改的实时可见。volatile 常用于多线程环境下的单次操作(单次读或者单次写)。
final域
final关键字
final在Java中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。一旦你将引用声明作final,你将不能改变这个引用了。
final域和线程安全有什么关系?
对于final域,编译器和处理器要遵守两个重排序规则。
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
可能执行的情况:
写final域重排序规则
JMM禁止编译器把final域的写重排序到构造函数之外。编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
读域的重排序规则
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作,编译器会在读final域操作的前面插入一个LoadLoad屏障。
溢出带来的重排序问题
可能出现的情况
Happens-Before模型
什么是Happens-Before
Happens-Before是一种可见性规则,它表达的含义是前面一个操作的结果对后续操作是可见的
6种Happens-Before规则
- 程序顺序规则
- 监视器锁规则,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
- Volatile变量规则
对一个volatile域的写,happens-before于任意后续对这个volatile域的读 - 传递性
如果A happens-before B,且B happens-before C,那么A happensbefore C - start()规则
如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的
ThreadB.start()操作happens-before于线程B中的任意操作
- Join()规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回
ThreadLocal
ThreadLocal是一个线程局部变量,我们都知道全局变量和局部变量的区别,拿Java举例就是定义在类中的是全局的变量,各个方法中都能访问得到,而局部变量定义在方法中,只能在方法内访问。那线程局部变量(ThreadLocal)就是每个线程都会有一个局部变量,独立于变量的初始化副本,而各个副本是通过线程唯一标识相关联的。
ThreadLocal的用法
主要方法:
方法 | 描述 |
---|---|
get() | 返回此线程局部变量的当前线程副本中的值 |
nitialValue() | 返回此线程局部变量的当前线程的“初始值” |
remove() | 移除此线程局部变量当前线程的值 |
set(T value) | 将此线程局部变量的当前线程副本中的值设置为指定值 |
initialValue() 这个方法是为了让子类覆盖设计的,默认缺省null。如果get()后又remove()则可能会在调用一下此方法。
remove() 移除此线程局部变量当前线程的值。如果此线程局部变量随后被当前线程 读取,且这期间当前线程没有 设置其值,则将调用其 initialValue() 方法重新初始化其值。这将导致在当前线程多次调用 initialValue 方法。
例:
public class ThreadLocalDemo {
private static Integer num=0;
public static final ThreadLocal<Integer> local=new ThreadLocal<Integer>(){
protected Integer initialValue(){
return 0; //初始值
}
};
public static final ThreadLocal<Integer> local1=new ThreadLocal<Integer>();
public static void main(String[] args) {
Thread[] threads=new Thread[5];
//希望每个线程都拿到的是0
for (int i = 0; i < 5; i++) {
threads[i]=new Thread(()->{
// num+=5;
int num=local.get(); //拿到初始值
local1.get();
num+=5;
local.set(num);
System.out.println(Thread.currentThread().getName()+"->"+num);
},"Thread-"+i);
}
for(Thread thread:threads){
thread.start();
}
}
}
运行结果
ThreadLocal的原理(摘自网上)
部分源码:
/**
* Sets the current thread's copy of this thread-local variable
* to the specified value. Most subclasses will have no need to
* override this method, relying solely on the {@link #initialValue}
* method to set the values of thread-locals.
*
* @param value the value to be stored in the current thread's copy of
* this thread-local.
*/
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
在这个方法内部我们看到,首先通过getMap(Thread t)方法获取一个和当前线程相关的ThreadLocalMap,然后将变量的值设置到这个ThreadLocalMap对象中,当然如果获取到的ThreadLocalMap对象为空,就通过createMap方法创建。
线程隔离的秘密,就在于ThreadLocalMap这个类。ThreadLocalMap是ThreadLocal类的一个静态内部类,它实现了键值对的设置和获取(对比Map对象来理解),每个线程中都有一个独立的ThreadLocalMap副本,它所存储的值,只能被当前线程读取和修改。ThreadLocal类通过操作每一个线程特有的ThreadLocalMap副本,从而实现了变量访问在不同线程中的隔离。因为每个线程的变量都是自己特有的,完全不会有并发错误。还有一点就是,ThreadLocalMap存储的键值对中的键是this对象指向的ThreadLocal对象,而值就是你所设置的对象了。 这个就是实现原理
/**
* Variant of set() to establish initialValue. Used instead
* of set() in case user has overridden the set() method.
*
* @return the initial value
*/
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
//获取和当前线程绑定的值时,ThreadLocalMap对象是以this指向的ThreadLocal对象为键
//进行查找的,这当然和前面set()方法的代码是相呼应的。进一步地,我们可以创建不同的
//ThreadLocal实例来实现多个变量在不同线程间的访问隔离,为什么可以这么做?因为不
//同的ThreadLocal对象作为不同键,当然也可以在线程的ThreadLocalMap对象中设置不同
//的值了。通过ThreadLocal对象,在多线程中共享一个值和多个值的区别,就像你在一个
//HashMap对象中存储一个键值对和多个键值对一样,仅此而已。
ThreadLocal实际用途
例1:在数据库管理中的连接管理类是下面这样的:(摘自网上)
public class ConnectionManager {
private static Connection connect = null;
public static Connection getConnection() {
if(connect == null){
connect = DriverManager.getConnection();
}
return connect;
}
...
}
在单线程的情况下这样写并没有问题,但如果在多线程情况下回出现线程安全的问题。你可能会说用同步关键字或锁来保障线程安全,这样做当然是可行的,但考虑到性能的问题所以这样子做并不是很优雅。
下面是改造后的代码:
public class ConnectionManager {
private static ThreadLocal<Connection> connThreadLocal = new ThreadLocal<Connection>();
public static Connection getConnection() {
if(connThreadLocal.get() != null)
return connThreadLocal.get();
//获取一个连接并设置到当前线程变量中
Connection conn = getConnection();
connThreadLocal.set(conn);
return conn;
}
...
}
例2:日期格式(摘自网上)
使用这个日期格式类主要作用就是将枚举对象转成Map而map的值则是使用ThreadLocal存储,那么在实际的开发中可以在同一线程中的不同方法中使用日期格式而无需在创建日期格式的实例。
public class DateFormatFactory {
public enum DatePattern {
TimePattern("yyyy-MM-dd HH:mm:ss"),
DatePattern("yyyy-MM-dd");
public String pattern;
private DatePattern(String pattern) {
this.pattern = pattern;
}
}
private static final Map<DatePattern, ThreadLocal<DateFormat>> pattern2ThreadLocal;
static {
DatePattern[] patterns = DatePattern.values();
int len = patterns.length;
pattern2ThreadLocal = new HashMap<DatePattern, ThreadLocal<DateFormat>>(len);
for (int i = 0; i < len; i++) {
DatePattern datePattern = patterns[i];
final String pattern = datePattern.pattern;
pattern2ThreadLocal.put(datePattern, new ThreadLocal<DateFormat>() {
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat(pattern);
}
});
}
}
//获取DateFormat
public static DateFormat getDateFormat(DatePattern pattern) {
ThreadLocal<DateFormat> threadDateFormat = pattern2ThreadLocal.get(pattern);
//不需要判断threadDateFormat是否为空
return threadDateFormat.get();
}
public static void main(String[] args) {
String dateStr = DateFormatFactory.getDateFormat(DatePattern.TimePattern).format(new Date());
System.out.println(dateStr);
}
}
总结
ThreadLocal是用冗余的方式换时间,而锁机制则是时间换空间,好的设计往往都是在时间、空间以及复杂度之间做权衡,道理是这样但是真正能平衡三者之间的人我姑且称之为“大成者”,愿你我在成长的道路上越走越远。