本文是Java线程安全和并发编程知识总结的一部分。
1 java语言提供的基本线程安全保护
本章节总结java语言本身自带的线程安全相关的知识点。
1.1 什么时候需要考虑线程安全
并不是所有情况都需要考虑线程安全,或者说,有些场景天生就是线程安全的,不需要我们做额外的工作。
1.1.1 方法栈保护下的线程安全。
前提:
- 类中的一个方法,没有返回对象(因此不会将对象发布出去),或返回不可变对象
- 方法参数不是对象,或是不可变对象
- 方法体中的代码没有访问类/对象的非常量成员变量(在讨论线程安全时,一般称为状态),或只访问常量成员变量,也没有访问其他类的成员变量。
线程安全的原因:
- 由于每个线程调用一个方法时,都会拥有自己的方法栈;该方法内的局部变量只存在于线程自己的方法栈中,因此不会在多线程之间共享,从而受到方法栈的线程安全保护。
- 方法不访问类/对象成员变量,或只访问常量成员变量,因此不存在多线程之间的资源竟态,不会线程不安全。
- 方法不和外界通过对象(本质是引用)共享数据,或线程外界共享的对象都是不可变对象,因此不存在资源竟态,也是安全的。
举例
- 最简单的方法栈提供的线程安全
该方法输入参数没有对象,都是基本类型;返回值也是基本类型;且不使用类/对象成员变量,也不访问其他类的成员。它受到方法栈保护,天生是线程安全的。
/**
* @author xxx
* 2020年1月31日 上午11:30:18
*/
public class Sample1 {
/**
* 该方法由方法栈提供线程安全保护
* 2020年1月31日 上午11:30:51 xxx 添加此方法
* @param a 参与计算的基础类型参数
* @param b 参与计算的基础类型参数
* @return 返回的也是基础类型
*/
public int calc1(int a, int b) {
int result = a * 10 + b;
result++;
return result;
}
}
- 输入参数或返回值使用不可变对象
该方法输入参数接收不可变对象或基本类型,返回值也是一个不可变对象;且方法内代码不访问类/对象的成员变量,也不访问其他类的成员。它受到方法栈保护,天生线程安全。
/**
* @author xx
* 2020年1月31日 下午2:39:31
*/
public class Sample2 {
/**
* 接受不可变对象和基本类型作为输入参数,返回不可变对象的方法,收到方法栈的线程安全保护。
* 2020年1月31日 下午2:50:17 xx添加此方法
* @param laltitude
* @param longitude
* @return
*/
public ImmutablePoint doSome2dCalc(ImmutablePoint point, double laltitude, double longitude) {
// 对坐标做一系列业务处理,得到新坐标。
double newLatitude = point.getLatitude();
double newLongitude = point.getLongitude();
// 返回一个不可变对象作为输出参数。
return new ImmutablePoint(newLatitude, newLongitude);
}
}
/**
* 不可变的点对象
* @author xx
* 2020年1月31日 下午2:41:48
*/
public class ImmutablePoint {
/**
* 构造函数
*/
public ImmutablePoint(double latitude, double longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
/**
* 维度
*/
private double latitude;
/**
* 经度
*/
private double longitude;
/**
* 获取属性 latitude 的值
* @return 属性 latitude 的值
*/
public double getLatitude() {
return this.latitude;
}
/**
* 获取属性 longitude 的值
* @return 属性 longitude 的值
*/
public double getLongitude() {
return this.longitude;
}
}
- 访问类的常量成员
当一个方法只访问所在类的常量成员变量时,显然该常量成员变量不可能形成竟态条件,因此是天生线程安全的。
/**
* @author xx
* 2020年1月31日 下午3:04:19
*/
public class Sample3 {
/**
* 常量成员
*/
private static final int Dummy_State = 1;
/**
* 只访问常量成员,受到方法栈的线程安全保护。
* 2020年1月31日 下午2:50:17 xx添加此方法
* @param a 基本类型参数
* @param b 基本类型参数
* @return
*/
public int calc(int a, int b) {
// 使用常量成员参与计算
int result = this.doCalc(Sample3.Dummy_State, a, b);
return result;
}
private int doCalc(int factor, int a, int b) {
int result = 0;
// 实际的计算逻辑
return result;
}
}
1.1.2 由调用者确保被调用方法不会被多线程访问
任何线程安全代码,都会增加代码复杂度。因为编程语言本身,实际上是基于串行的。因此,当非常明确某个业务方法不会被用于多线程访问时,就无需进行线程安全保护处理。
典型的场景包括:
1. 供不支持并发执行的定时器调用的方法
比如,定时器框架quartz允许将定时器配置为支持并行执行或不支持并行执行。
当配置为不支持并行执行时,如果一个定时器的前一次执行尚未结束,下一次执行的时间又到了的话,下一次执行将被阻塞,直到前一次执行结束才启动执行。
2. 只执行一次的初始化逻辑
比如由spring的@PostConstruct注解的单例bean上的方法:
/**
* @author xx
* 2020年1月31日 下午3:48:15
*/
@Service
public class Sample4 {
/**
* 只在一个线程中执行一次
* 2020年1月31日 下午3:49:12 xx添加此方法
*/
@PostConstruct
public void init() {
// 在独立线程中执行,避免影响spring容器的初始化。
new Thread(() -> {
this.loadCacheFromDb();
}).start();
}
/**
* 本方法不提供线程安全保护,由调用者确保该方法只被一个线程调用。
* 2020年1月31日 下午3:55:48 xx添加此方法
*/
private void loadCacheFromDb() {
// 初始化逻辑,比如从数据库中加载需要缓存的数据等
}
}
3. 类的静态初始化代码块。
类的静态初始化块,是在类被jvm初始化时调用的,由虚拟机的内部同步机制确保其线程安全。
/**
* @author xx
* 2020年1月31日 下午4:00:01
*/
public class Sample5 {
/**
* 一个字符串键值对缓存容器。
*/
private static Map<String, String> caches;
/**
* 静态初始化块,由jvm内部同步机制确保其线程安全。
*/
static {
caches = new HashMap<>(10);
for (int i = 0; i < 10; i++) {
caches.put(Integer.toString(i), "some value " + i);
}
}
}
1.1.3 由调用者提供线程安全保护
即某个方法本身并未提供线程安全保护,且形成了竟态条件,是线程不安全的。但是在业务上可以确保该方法总是被另外一个方法调用,且调用该方法的代码块有线程安全保护。那么,这个方法实际上构成了事实线程安全。
比如:
/**
* @author xx
* 2020年1月31日 下午4:08:45
*/
public class Sample6 {
/**
* 表示当前类状态的成员变量
*/
private int state;
/**
* 只访问常量成员,受到方法栈的线程安全保护。
* 2020年1月31日 下午2:50:17 xx添加此方法
* @param a 基本类型参数
* @param b 基本类型参数
* @return
*/
public int calc(int a, int b) {
int result = 0;
// 通过内部锁确保只有一个线程调用这段代码。
// doCalc方法的调用者提供了线程安全保护,是线程安全的。
synchronized (this) {
// 先执行一些逻辑
result = this.doCalc(a, b);
// 再执行一些逻辑
}
return result;
}
/**
* 该方法本身不提供线程安全保护,本身不具备线程安全,由其调用者提供保证。
* 2020年1月31日 下午4:09:53 xx添加此方法
* @param a 基础类型变量
* @param b 基础类型变量
* @return
*/
private int doCalc(int a, int b) {
int result = 0;
// 内部成员变量加入计算,形成竟态条件,存在线程安全问题
if (this.state < 0) {
this.state++;
// 执行计算逻辑1,并将结果赋值给 result
} else if (this.state == 0) {
// 执行计算逻辑2,并将结果赋值给 result
} else {
this.state--;
// 执行计算逻辑3,并将结果赋值给 result
}
return result;
}
}
这类场景往往重度依赖相关代码充分注释,或详细的文档说明,以及开发团队的管理。很容易因为人为因素导致线程安全被破坏。
1.2 使用java语言的内置锁机制(synchronized语法)
Java语法的synchronized关键词,提供了使用Java提供的内置可重入锁的机会。Jvm为每个对象默认提供了一个可重入锁;无论你是否使用,该锁都存在,因此称为内置锁。
它有两种用法:
1. 用于方法
该关键词用于方法时,若该方法被调用,进入该方法时会自动尝试获得当前对象的内置锁,如果获取内置锁失败,则当前线程阻塞;退出该方法时会自动释放当前对象的内置锁。
由于是使用方法所在对象的内置锁,因此任意时候,只有一个线程可以执行该方法,其他调用该方法的线程都会被阻塞。
/**
* @author xx
* 2020年1月31日 下午4:08:45
*/
public class Sample7 {
/**
* 表示当前类状态的成员变量
*/
private int state;
/**
* 2020年1月31日 下午4:38:11 xx 添加此方法
* @param args
*/
public void startCalc() {
// 虽然启动了5个线程,但由于内部锁的存在,实际上任意时刻都只有一个线程在执行,其他线程都被阻塞了
for (int i = 0; i < 5; i++) {
int times = i;
int a = 5 + i;
int b = 7 + i;
new Thread(() -> {
int result = this.calc(a, b);
System.out.println(" i = " + times + ", result = " + result);
}).start();
}
}
/**
* 由 synchronized 提供线程安全保护
* 2020年1月31日 下午2:50:17 xx添加此方法
* @param a 基本类型参数
* @param b 基本类型参数
* @return
*/
private synchronized int calc(int a, int b) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
int result = 0;
// 先执行一些逻辑
// 内部成员变量加入计算,形成竟态条件,存在线程安全问题
if (this.state < 0) {
this.state++;
// 执行计算逻辑1,并将结果赋值给 result
} else if (this.state == 0) {
this.state -= 2;
// 执行计算逻辑2,并将结果赋值给 result
} else {
this.state--;
// 执行计算逻辑3,并将结果赋值给 result
}
// 再执行一些逻辑
return result;
}
}
2. 用于代码块
该关键词用于代码块时,必须用一个对象做锁。我们可以使用任意对象来作为 synchronized 关键词的锁;其实质,是使用给定对象的内部锁作为锁。
比如:
synchronized(this) ,实际上是使用 this 对象的内部锁做锁。比如 Sample6。
synchronized(someObj),实际上是使用对象 someObj 的内部锁做锁。
3. 注意
- synchronized 语义实现的是不可中断锁。
也就是说,当前进入同步代码块以后,及时代码跑出 InterruptException,代码也不会中断,因为不响应中断异常。具体参看2.2.1 可重入锁与不可重入锁,其中详细举例说明。
- 当使用字符串做锁时,因为jvm字符串常量池的存在,要使用 string.intern() 做锁,不要直接用字符串。
因为jvm字符串常量池的存在,为了避免两个字面值相同的字符串实际上指向不同的对象,要使用 string.intern() 做锁,不要直接用字符串。string.intern()方法返回相同字符串在字符串常量池中的对象引用,确保了对相同字符串内容,得到的是同一个对象。
1.3 使用Java语言的volatile机制提供的弱同步保护
这个关键词的作用有两个:
- 是告诉虚拟机,不要将该关键词修饰的类成员变量,存放在寄存器或其他处理器不可见的地方。这样,一旦该成员变量被修改,能够立即被所有线程看到。也就是能提供同步的可见性;但它不提供同步的原子性,因此是不完全的同步保护,是弱同步保护。
- 在方法中访问该关键词修饰的成员变量时,不要参与重排序。关于jvm重排序,不了解的自行百度。
其优点在于:
比同步效率高,当没有复合原子操作时,可以用该关键词提供可见性保护。
其缺点在于:
正如其优点所言,它只保证可见性保护,不确保原子性保护。
显然,该关键词不能用于有多个相关状态属性的场景,此时必然出现竟态条件,有原子性保护需求。
事实上,当需要该关键词的场景,都可以使用java.util.concurrent.atomic
包提供的原子量来替代。
网友评论