1 简介
- 看本文前,请先了解 JMM,本文大部分参考《JAVA并发编程实战》
- 本质原因:某一个线程在观察和修改一个变量的过程中,另一个线程修改了变量的值,导致本线程的观测结果无效,进而使得计算结果无效,所以本线程修改变量会出问题。
2 示例
- 问题:如果有两个线程同时使用了这个函数,则a的结果在每次程序运行后,结果都不一样!
- 原因:
- a++分为三步,1)从内存中提取a的值;2)计算a+1;3)修改a的值。
- 如果线程1观测了a的值后,线程2修改了a的值。此时,线程 1观测的结果无效,导致计算结果是错误的。
private int a = 0;
public void demo(){
for(int i=0;i<1000;i++){
a++;
}
}
3 上锁
3.1 基本概念和应用
- java锁的特性
- 缺点:上锁的同时意味着极大地降低了并发性能。
- java的锁具有可重入性,就是线程可以重复获取自己持有的锁。即当一个同步方法块调用另一个同步方法块的时候,如果两个同步代码块的锁一致,就不会发生死锁问题。
- 上锁的实现:线程在进入synchronized同步代码块时,会先去获取锁,如果抢到锁之后,即可运行同步代码块,当运行完同步代码块后,将释放锁。所以,如果锁有多个实例的话,那上锁就没有意义,因为每个线程都能抢到一把锁。
- 原子性操作:即计算机运行的最小的同步指令。(未上锁的a++操作包含三个原子性操作,查询、计算、修改)
- 死锁问题:两个线程调用了不同的同步代码块,而不同的同步代码块内之间又相互调用了,导致两个线程都需要抢夺对方已抢夺到的锁,所以两个线程同时陷入阻塞状态,程序无法继续运行。
- 锁的应用与注意:
- 如果需要在观测状态之后,计算和修改状态,则在观测状态前需要加锁。
- 在需要长时间运行的或者有阻塞的方法上,最好不要上锁
- 应当让原子性操作的范围尽可能的小,尽量将不需要同步运行的代码剥离出来。例如如果没有操作一个实例对象如果没有操作静态变量,可以考虑使用this上锁;而如果操作的是静态变量,需要考虑用一个静态变量上锁。
3.2 示例
- 使用同步代码块,保证判断与修改必须同步进行
- 但是,锁一定要统一,比如下文中的匿名方法的调用,会创建四个对象,所以不能用this来上锁,但是可以用 this.class 来上锁或者创建一个静态成员变量(公用)来上锁。
- 如果 synchronized 修饰方法,则锁住的是 this
public class Demo4_Ticket {
/**
* @param args
* 火车站卖票的例子用实现Runnable接口
*/
public static void main(String[] args) {
MyTicket mt = new MyTicket();
new Thread(mt).start();
new Thread(mt).start();
new Thread(mt).start();
new Thread(mt).start();
/*Thread t1 = new Thread(mt); //多次启动一个线程是非法的
t1.start();
t1.start();
t1.start();
t1.start();*/
}
}
class MyTicket implements Runnable {
private int tickets = 100;
@Override
public void run() {
while(true) {
synchronized(MyTicket.class) {
if(tickets <= 0) {
break;
}
try {
Thread.sleep(10);//线程1睡,线程2睡,线程3睡,线程4睡
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "...这是第" + tickets-- + "号票");
}
}
}
}
3.3 线程安全的类
- Vector,StringBuffer,Hashtable
- Collections.synchroinzed(xxx):将线程不安全的集合变为线程安全的集合
- 可以自制一个线程安全的类,将多个线程需要共享的数据放入其中。对共享数据的观测和修改提供同步方法,这样就使得操作该类对象一定是线程安全的。
4 避免并发错误
4.1 分析
必须先分析出每个线程需要共享的量
4.2 方法
指导思想:将取操作和存操作和并为原子操作,在取操作到存操作的过程中不允许其他线程操作共享对象。
- 在设计阶段,直接使用线程封闭技术(比如利用ThreadLocal类,将变量与线程绑定。),不允许某个变量被线程共享
- 使用 volatile 关键字保证对象在线程之间的可见性。
- 该关键字适用于一个线程操作,一个线程只进行观察的情况下。
- 该关键字不能保证对对象的观察和修改变成原子性操作。
- 上锁
- 上锁是最直接的方式,最好观察以下操作的是不是静态变量,如果不是则使用this上锁即可。如果是,则需要使用静态变量作为锁。因为要在保证原子操作的基础上,尽可能小的使用锁。
- 将对象放入 final 域中存储,在创建final域对象和提取final域对象的时候使用深拷贝(这样可保证存储的数据不被外界操作),也可以保证线程之间的可见性,同时保证了线程安全。
- 使用或者制作线程安全的类
- 要保证对象的取操作和存操作的过程中是上锁的。如果取操作和存操作中间有一些其他操作,可以单独提炼出一个接口。比如下面的这个示例,当然如果你只想大致的查阅一下当前对象内的信息,而对脏读问题没有要求的话(不修改也不用于其他计算),那可以再建立一个变量用于缓存数据,这样可以保证某线程查看数据的时候不被正在操作对象的线程给堵塞。
package pojo;
import java.util.ArrayList;
import java.util.List;
/**
* 要求对所有成员变量的操作是线程安全的
* @Author :吴用
* @Date :2020/11/14 17:06
* @Version :1.0
*/
public class SafeElement {
private ArrayList<Integer> list = new ArrayList<>();
private Boolean flag = false;
/**
* 获取和修改list,也可以只读取不修改
* @param handler
* @return
*/
public SafeElement getAndSetList(Handler<List<Integer>> handler){
synchronized (this.list){
handler.handlerMethod(this.list);
}
return this;
}
/**
* 获取和修改flag,也可以只读取不修改
* @param handler
* @return
*/
public SafeElement getAndSetFlag(Handler<Boolean> handler){
synchronized (this.flag){
handler.handlerMethod(this.flag);
}
return this;
}
/**
* 处理的接口
* @param <T>
*/
public interface Handler<T>{
void handlerMethod(T oldVar);
}
}
// 验证:
@Test
public void demo1(){
SafeElement safeElement = new SafeElement();
new Thread(()->{
// 线程1的操作
safeElement.getAndSetList(oldVar->{
for (int i = 0; i < 100; i++) {
oldVar.add(i);
}
});
}).start();
new Thread(()->{
// 线程1的操作
safeElement.getAndSetList(oldVar->{
for (int i = 100; i < 200; i++) {
oldVar.add(i);
}
});
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
safeElement.getAndSetList(oldVar->{
System.out.println(oldVar);// 可以按照顺输出,可能是从0开始,也可能是从100开始
});
}