1.线程安全
线程不安全:单看代码“没有问题”的情况下,但结果是错误的(无法 100% 得到预期结果)
线程安全:代码的运行结果应该是 100% 符合预期
2.线程不安全现象出现的原因
1.站在开发者的角度
1)多个线程之间操作同一块数据了(共享数据)——不仅仅是内存数据
2)至少有一个线程在修改这块儿共享数据
多个线程中至少有一个对共享数据做修改(写)操作
2.系统角度
会出现线程安全原因:
我们的预期 r++ 或者 r-- 是一个原子性操作(全部完成 or 全部没完成),但实际执行起来,保证不了原子性,所以会出错。
系统角度分析出现线程不安全的原因
1.内存可见性问题
内存可见性:一个线程对数据的操作,很可能其他线程是无法感知的。甚至,某些情况下,会被优化成完全看不到的结果。
2.代码重排序导致的问题
我们的写程序,往往是经过中间很多环节优化的结果,并不保证最终执行的语言和我们写的语句是一模一样的。
所谓重排序就是:执行的指令和不书写指令并不一致。
JVM 规定了一些重排序的基本原则:happend-before 规则
简要的解释:JVM 要求,无论怎么优化,对于单线程的视角,结果不应该有改变。但并没有规定多线程环境的情况(并不是不想规定,而是不能规定)导致在多线程环境下可能出问题。
3.小结线程安全问题
1.所谓什么是线程安全
- 程序的线程安全:运行结果100% 符合预期(这个标准无法实操,只是为了理解)
- Java 语境下,常说某个类、对象是线程安全的:这个类、对象的代码中已经考虑了处理多线程的问题了,如果只是“简单”使用,可以不考虑线程安全的问题。ArrayList 就不是线程安全的。——ArrayList 实现中,完全没考虑过线程安全的任何问题。无法直接使用在多线程环境(多个线程同方式操作同一个ArrayList)
2.作为程序员如何考虑线程安全的问题
- 尽可能让几个线程之间不做数据共享,各干各的。就不需要考虑线程安全问题了。比如:4个线程虽然处理的是同一个数组,但提前划好范围,各做各的,就没问题了
- 如果非要有共享操作,尽可能不去修改,而是只读操作 static final int COUNT = ...; 即使多个线程同时使用这个 COUNT 也无所谓的
- 一定会出现线程问题了,问题的原因从系统角度讲:1)原子性被破坏了2)由于内存可见性问题,导致某些线程读取到了“脏数据” 3)由于代码重排序导致的线程之间关于数据的配合出问题了
所以,我们需要学习一些机制,目标和JVM 沟通,避免上述问题的发生
4.一些常见类的线程安全问题
ArrayList、LinkedList、PriorityQueue、TreeMap、TreeSet、HashMap、HashSet 、StringBuilder 都不是线程安全的。
ArrayList为什么不是线程安全的?——多个线程同时对一个 ArrayList 对象有修改操作时,结果会出错。
1.有没有共享数据:ArrayList对象共享,array属性和size属性是共享的
2.多线程有没有写操作?有。array[size] = e;size++; 也是修改
3.原子性(90%情况都是原子性破坏) or 可见性 or 代码重排序 ?
array[size] = e;size++; 应该是原子的,但目前不是
线程安全:Vector、Stack、Dictionary、StringBuffer 。这几个类都是 Java 设计失败的产品。最好不要出现这些类。
5.锁(lock)
synchronized 锁 同步锁/monitor锁(监视器锁)
1.语法
- 修饰方法(普通、静态方法) => 同步方法 synchronized int add(...){...}
- 同步代码块 synchronized(引用){ ... }
public class Main {
// sync 修饰普通方法
public synchronized int add(){
return 0;
}
// sync 修饰静态方法
private synchronized static int sub(){
return 0;
}
// sync 同步代码块
public void method(){
Object o = new Object();
synchronized (o){
}
}
// sync 同步代码块
public void method1(){
Object o = new Object();
synchronized (Main.class){
}
}
}
2.锁
锁理论上,就是一段数据(一段被多个线程之间共享的数据)
所有两种状态:锁上(locked)、打开(unlocked)
尝试加锁的内部操作:
1.整个尝试加锁的操作已经被 JVM 保证了原子性
if(lock == false){
// 说明这个锁没有被锁上
lock = true;// 当前线程把锁
lock return;// 正常向下执行一些语句
}
// 如果锁已经 locked(true) 了
Queue<线程> 该锁的阻塞队列 queue = ... ;
queue.add(Thread.currentThread()); 等待这把锁被释放
// 既然我们无法获取到锁,所以就应该让出 CPU
Thread.currentThread().state = 阻塞;
Thread.yield(); // 理解成让出 CPU
由此我们得出:
无锁:语句1;语句2;
有锁:语句1;sync(ref){ 语句2;... }
语句1的执行时间 和 语句2的执行时间相隔很久。甚至极端情况下,语句2 再也不会被执行 都是有可能的。
3.释放锁
这个过程由系统保证了原子性
当多个线程 都有加锁操作时 并且 申请的是同一把锁时 ,会造成 加锁 代码(临界区) 解锁
临界区代码会互斥(互相排斥)着进行。和临界区的代码是不是同一份代码无关
public class Main {
// 这个对象用来当锁对象
static Object lock = new Object();
static class MyThread1 extends Thread {
@Override
public void run() {
synchronized (lock) {
for (int i = 0; i < 1_0000; i++) {
System.out.println("我是张三");
}
}
for (int i = 0; i < 1_0000; i++) {
System.out.println("我是王五");
}
}
}
static class MyThread2 extends Thread {
@Override
public void run() {
synchronized (lock) {
for (int i = 0; i < 1_0000; i++) {
System.out.println("我是李四");
}
}
for (int i = 0; i < 1_0000; i++) {
System.out.println("我是赵六");
}
}
}
public static void main(String[] args) {
Thread t1 = new MyThread1();
t1.start();
Thread t2 = new MyThread2();
t2.start();
}
}
临界区的代码是必须在持有锁的前提下才能执行的。
张三、李四是有锁的,王五、赵六是没有锁的
张三、李四是互斥的;张三赵六不是互斥的;王五、李四不是互斥的;张三、王五是一个线程的;李四、赵六是一个线程的
最终结果会是 先打印 张三,张三释放锁之后,才会打印李四,因为王五、李四不是互斥的,所以会是王五、李四交替打印,最后打印赵六。
小练习:
s1 = new SomeClass(); s2 = new SomeClass(); s1 = s3
序号 | t1线程 | t2线程 | t1 是否加锁,哪把锁 | t2 是否加锁,哪把锁 | 是否互斥 |
1 | s1.m1() | s1.m1() | 加锁 s1 指向的对象 | 加锁 s1 指向的对象 | 互斥 |
2 | s1.m1() | s3.m1() | 加锁 s1 指向的对象 | 加锁 s3 指向的对象 | s1 == s3 互斥 |
3 | s1.m1() | s2.m1() | 加锁 s1 指向的对象 | 加锁 s2 指向的对象 | s1 != s2 不互斥 |
4 | s1.m1() | s1.m2() | 加锁 s1 指向的对象 | 加锁 SomeObject.class对象 | 不是同一个对象 不互斥 |
5 | s1.m1() | s1.m3() | 加锁 s1 指向的对象 | 无加锁 | 不互斥 |
6 | s1.m1() | s1.m4() | 加锁 s1 指向的对象 | 加锁 s1 指向的对象 | 互斥 |
7 | s1.m1() | s3.m4() | 加锁 s1 指向的对象 | 加锁 s3 指向的对象 | s1 == s3 互斥 |
8 | s1.m1() | s1.m5() | 加锁 s1 指向的对象 | 加锁 SomeClass.class 对象 | 不互斥 |
9 | s1.m2() | s1.m5 | 加锁 SomeClass.class 对象 | 加锁 SomeClass.class 对象 | 互斥 |
10 | s1.m1() | s2.m6() | 加锁 s1.o1 指向的对象 | 加锁 s2.o1 指向的对象 | 不互斥 |
11 | s1.m6() | s2.m6() | 加锁 s1.o1 指向的对象 | 加锁 s2.o1 指向的对象 | 不互斥 |
12 | s1.m6() | s3.m6() | 加锁 s1.o1 指向的对象 | 加锁 s3.o1 指向的对象 | s1 == s3 互斥 |
13 | s1.m2() | s1.m7() | 加锁 SomeClass.clss 对象 | 加锁 SomeClass.o2 加锁 | 不互斥 |
14 | s1.m7() | s1.m7() | 加锁 SomeClass.o2 加锁 | 加锁 SomeClass.o2 加锁 | 互斥 |
15 | s1.m7() | s3.m7() | 加锁 SomeClass.o2 加锁 | 加锁 SomeClass.o2 加锁 | 互斥 |
16 | s1.m7() | s2.m7() | 加锁 SomeClass.o2 加锁 | 加锁 SomeClass.o2 加锁 | 互斥 |
6.synchronized加锁操作
加锁操作使得互斥(synchronized 和我们一起配合(我们需要正确地使用 synchronized))
synchronized的作用:原子性、可见性、重排序
1.保证了临界区的原子性
这两种加锁的方式,加锁的“粒度”不同
左边:加锁粒度较细 右边:加锁粒度较粗
粗略的,加锁粒度越细,并发性可能性越高。粒度不是越粗越好,也不是越细越好。
这种加锁是不正确的,两个指向的对象不同,不是一把锁。达不到我们想做到的互斥(原子性的保证)
用 sync 来保证原子性,需要通过正确的加锁,来保证代码间的互斥。
2.synchronized 在有限程度上可以保证内存可见性
临界区期间的数据读写,不做保证:1.可能读的数据,再次被别的线程更改了,就看不到了 2.期间是否有数据同步回主内存
3.synchronized 也可以给代码重排序增加一定的约束
s1;s2;s3 加锁;s4;s5;s6;解锁;s7;s8;s9;
s1、s2、s3 之间不做保证
s4、s5、s6 之间不做保证
s7、s8、s9 之间不做保证
s4、s5、s6 不会重排序到加锁之前的,解锁之后的
s1、s2、s3 不会到加锁之后
s7、s8、s9 不会到解锁之前的
7.lock锁
java.util.concurrent,locks.Lock;
// 加锁,但允许被中断;加锁失败后返回false
// 相较于sync 锁,加锁策略更灵活
void lock();
void lockInterruptibly();
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throw InterruptedException;
// 解锁操作
boolean unlock();
java 中方法的结束以两种形式出现:1.正常结束,并返回 2.异常结束,抛出异常
几种情况可能导致该方法结束?
1.超时时间内,加锁成功了,正常返回,返回true
2.超时时间到了,加锁失败,正常返回,返回false
3.超时时间内,加锁还没成功 但是 线程被终止了,异常返回,捕获到 InterruptedException
8.ReentrantLock 锁
Lock lock = new ReentrantLock();
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // 将unlock 放到finally 里,确保任何情况下都能解锁
}
9.synchronized 锁 VS juc 下的锁
synchronized 锁:代码的书写就保证一定有锁的释放
sync(锁){...} 只有一种类型的锁 一直请求锁
juc 下的锁:是可能忘记写 lock.unlock() 导致锁一直不释放的
书写更灵活:可以一个方法加锁,到另一个方法中解锁
锁的类型更加灵活:公平锁、非公平锁、读写锁、独占锁、可重入锁、不可重入锁
锁的加锁策略更灵活:1.一直请求锁 2.带中断 3.尝试请求 4.带超时的尝试
10. 线程状态——阻塞状态
(blocked、waiting、timed_waiting)
blocked:专指请求 synchronized 锁失败时的状态
waiting VS timed_waiting
lock.lock() 一般带时间
lock.lockInterruptly Thread.sleep(...)、t.join(5000)、lock.tryLock(5s)
t.join()