1.共享带来的问题
多线程下分时系统中,CPU会轮流给每个线程使用,当时间片用完时,或者当线程处于sleep,IO阻塞,wait状态时(统称阻塞态),会把CPU资源分给其他线程去使用。当多个线程访问共享资源的时候,因为这个原因就可能产生一些错误。
例如t1线程对数据进行特定操作,还未操作共享资源时,CPU已经给t2线程使用,t2线程写入数据,当t1重新拿到处理机继续执行操作,可是此时的数据结果已经并非t1或者t2所希望得到的。
1.1Java的体现
public class Test01 {
//定义共享资源
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
//对共享资源进行自增操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter++;
}
}, "t1");
//对共享资源执行自减操作
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
//希望得到结果为0
System.out.println(counter);
}
}
1.2问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
对于i++操作(i为静态变量),实际上会产生如下JVM字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
当一个线程对内存中的i进行操作时,可能指令还未执行完,另一个线程也对i进行操作,直观来说就是不同线程间的指令交替执行了最终造成结果错误
1.3临界区 Critical Section
多个线程读取共享资源没有问题
多个线程对共享资源的读写操作时指令发生交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区(指的是操作)
//共享资源
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}
1.4竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同(字节码指令发生交错)而导致结果无法预测,称之为发生了竞态条件
2.synchronized 解决方案
2.1应用之互斥
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
java 中互斥和同步都可以采用 synchronized 关键字来完成,但还是有区别的:
-
互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
-
同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
2.2synchronized
语法
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}
使用(解决上面提到的结果错误问题)
public class Test01 {
static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter);
}
2.3理解
可以把synchronized (room)看作是一个操作间,只有线程进入到这个操作间之后,才可以执行临界区的代码,其他线程都无权干扰打断。
哪怕是使用线程cpu时间片用完(发生上下文切换),其他线程如果想要进入操作间就会变成阻塞状态,只要没有释放其他线程仍然没有使用权,在这个房间被释放前都不能进入操作间进行临界区的操作。
当拥有锁的线程执行完毕就会释放锁,放开操作间使用资格,并且唤醒其他的阻塞线程,并且该线程就成为锁的拥有者。
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
- 问题
如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 仍然生效,增加了临界区代码,原子性
如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象不同,类比于有两个操作间,对同一资源加锁,锁对象需要一致
如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 加锁不完全,对于代码的原子性没有保障
2.4面向对象改进
把需要保护的共享变量放入一个类
class Room {
int value = 0;
public void increment() {
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
synchronized (this) {
return value;
}
}
}
public class Test01 {
public void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(room.get());
}
}
3.方法上的 synchronized
这样锁加在这个对象上,锁的是调用这个方法的对象
class Test{
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
static锁的是整个类对象
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
不加 synchronized 的方法
不加 synchronzied 的方法就好比不遵守规则的人,不去老实排队(好比翻窗户进去的)
4.变量的线程安全分析
4.1成员变量和静态变量是否线程安全?
-
如果它们没有被共享则线程安全
-
如果他们被共享,根据它们的状态能否被改变,分两种情况
- 如果只有读操作,线程安全
- 如果既有读又有写操作,则这段代码是临界区,需要考虑线程安全
4.2局部变量是否线程安全?
-
局部变量是线程安全的
-
但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
4.3局部变量线程安全分析
public static void test1() {
int i = 10;
i++; }
每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享
成员变量例子
import java.util.ArrayList;
public class Test02 {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafe test = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}
class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2();
method3();
// } 临界区
}
}
private void method2() {
list.add("1");
}
private void method3() {
list.remove(0);
}
}
其中一种情况是,如果线程2 还未 add,线程1 remove 就会报错:
Exception in thread "Thread1" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
at java.util.ArrayList.rangeCheck(ArrayList.java:657)
at java.util.ArrayList.remove(ArrayList.java:496)
at cn.itcast.n6.ThreadUnsafe.method3(TestThreadSafe.java:35)
at cn.itcast.n6.ThreadUnsafe.method1(TestThreadSafe.java:26)
at cn.itcast.n6.TestThreadSafe.lambda$main$0(TestThreadSafe.java:14)
at java.lang.Thread.run(Thread.java:748)
分析
1.无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量
2.method3 与 method2 分析相同
解决:
class ThreadSafe {
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
//将上述堆中建立的list作为参数传递到method2
private void method2(ArrayList<String> list) {
list.add("1");
}
//同理
private void method3(ArrayList<String> list) {
list.remove(0);
}
}
当不同线程去调用method1方法时(之后执行method2和method3),都会在堆中创建各自的list对象,之后对自身的list对象进行操作就不存在共享问题
分析:
list 是局部变量,每个线程调用时会创建其不同实例,没有共享
而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
method3 的参数分析与 method2 相同
关键:局部变量的引用没有暴露给外部的时候是安全的
当局部变量暴露给外部
方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题
class ThreadSafe {
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
public void method2(ArrayList<String> list) {
list.add("1");
}
public void method3(ArrayList<String> list) {
list.remove(0);
}
}
方法2,3有可能被其他方法调用
情况1:有其它线程调用 method2 和 method3
考虑:例如线程1调用method1,线程2直接调用method2
不会存在线程安全问题,1调用method1使用的是自身在堆中创建的list对象,2调用的method2传入的list不是1调用堆中的list
情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,即
class ThreadSafe {
//增加final防止子类重写,覆盖方法
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
public void method2(ArrayList<String> list) {
list.add("1");
}
public void method3(ArrayList<String> list) {
list.remove(0);
}
}
//子类
class ThreadSafeSubClass extends ThreadSafe {
@Override
public void method3(ArrayList<String> list) {
//若method3被调用传入list对象
//内部就有一个新线程与之前线程共享list
new Thread(() -> {
list.remove(0);
}).start();
}
}
这里可以理解为主线程创建list对象使用,调用方法mehtod1传入了list对象,当调用到method3时会开启一个新线程同时把该list对象传入使用,就形成了共享
从这个例子可以看出 private 或 final 提供【安全】的意义所在,如此子类中的方法就无法覆盖,体会开闭原则中的【闭】
5.线程安全类
- String
- Integer
- StringBuffffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类 简称JUC
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为
- 它们的每个方法是原子的
- 但注意它们多个方法的组合不是原子的,见后面分析
public void main(String[] args) {
//保障线程安全的
Hashtable table = new Hashtable();
new Thread(() -> {
table.put("key", "value1");
}).start();
new Thread(() -> {
table.put("key", "value2");
}).start();
}
不被线程上下文切换所侵扰,保证方法中代码的原子性
- 它们的每个方法是原子的
- 但注意它们多个方法的组合不是原子的,见后面分析
5.1线程安全类方法的组合
多个方法组合使用不一定线程安全
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
说明:线程1执行到table.get("key") == null判断完就发生了上下文切换,未进行后续的put操作,线程2执行 table.get("key") == null,并且完成了put操作,此时上下文切换到线程一,线程一继续执行put方法,此时就发生了错误。
5.2不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态(属性)不可以改变,因此它们的方法都是线程安全的
思考:String 有 replace,substring 等方法可以改变值,那么这些方法又是如何保证线程安全的呢?
String.substring本质上是创建了一个新的字符串对象对原字符串做一个复制
根本没有改变值(改变对象的属性)