1.多线程出现问题的原因
1.1 原因分析
多线程出现共享问题的根本原因就是由于线程上下文切换,导致线程里的指令没有执行完就切换其它线程来操作了。(这里多线程访问的是共享资源)
典型例子:
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 1;i<5000;i++){
count++;
}
});
Thread t2 =new Thread(()->{
for (int i = 1;i<5000;i++){
count--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count的值是{}",count);
}
输出结果count的值不是0,有可能是正数也有可能是负数。
问题分析:
这需要从字节码的层面来理解,i++
这个操作在java代码层面看是一个指令,而在字节码层面看,它是多个指令,不是一个原子操作。
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
我们看到count++
和count--
这两个操作实际上是需要4个指令完成的。在Java内存模型下,完成静态变量的自增和自减需要在主内存和工作内存之间进行数据交换。因此,这也就是问题所在,当一个线程的count++
中的4个指令还没有运行完,就发生了线程上下文切换,就要出问题了!
上述代码出现负数的情况:
上述代码出现正数的情况:
1.2 问题的专业描述
临界区(Critical Section)
1)一个程序运行多个线程这个本省身是没有问题的
2)问题的原因在于多个线程访问的是共享资源(多个线程都可以访问的,比如Java堆、方法区)
-
多个线程同时读共享资源也是没有问题的
-
而多个线程同时对共享资源进行读写操作就会出现问题了(读写操作发生指令交错会出问题)
3)临界区: 一段代码存在对共享资源的多线程读写操作时,称这段代码为临界区
例如:
static int count = 0;
static void increment() {
//临界区
count ++;
}
static void decrement() {
//临界区
count --;
}
竞态条件(Race Condition)
竞态条件:
多个线程在临界区执行,由于代码的执行序列不同而导致结果无法预测
1.3 解决方案
解决目的就是避免竞态条件的发生,有两大类解决方法:
1)阻塞式的解决方案:synchronized, Lock
2)非阻塞式的解决方案:原子变量(CAS)
2.synchronized
解决方案
1)解决思路:
synchronized
(也叫对象锁)的解决办法是采用互斥的方法,让同一时刻至多只有一个线程能持有【对象锁】,其它线程想获得【对象锁】就会阻塞住,这样就可以保证同一时刻只有拥有锁的线程可以执行临界区的代码。
原理: synchronized
利用【对象锁】保证了临界区代码的原子性,临界区的代码在外界看来是不可分割的,不会被线程切换打断。
2)synchronized
语法:
synchronized(对象) { //线程1, 线程2(blocked)
//临界区
}
上述例子的解决:加上synchronized之后,结果正确了!
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();
log.debug("{}",counter);
注意:
1)线程t1拿到锁之后,并不是一直执行下去的,当t1的CPU时间片用完了,它会暂停下来,但并不会释放锁;这时t2是拿不到锁的,仍然会阻塞着,当t1重新运行时,又可以继续执行下去,执行结束才会释放锁。
2)
synchronized
必须锁同一个对象才有效;当两个synchronized
锁的不是一个对象,就会失效3)
synchronized
时必须在临界区都加锁,只有一个加锁也会失效
3) synchronized
加在方法上
① synchronized加到成员方法上
class Test{
public synchronized void test() {
}
}
//等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
② synchronized加到静态方法上
class Test{
public synchronized static void test() {
}
}
// 等价于,锁的是类的class对象
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
注意: synchronized只能锁对象!
3. 变量的线程安全分析
3.1 成员变量和静态变量的线程安全分析
两种情况:共享和非共享
1)如果它们没有被共享,则它们线程安全
2)如果它们被共享了,又分为两种情况:
①如果只有读操作,则仍是线程安全的
②如果有读写操作,则这段代码就是临界区,存在线程安全问题。
成员变量线程不安全的例子:
不安全原因分析:
@Slf4j(topic = "c.Code_18_Test")
public class Code_18_Test {
public static void main(String[] args) {
UnsafeTest unsafeTest = new UnsafeTest();
for(int i = 0; i < 10; i++) {
new Thread(() -> {
unsafeTest.method1();
}, "t" + i).start();
}
}
}
class UnsafeTest {
List<Integer> list = new ArrayList<>();
public void method1() {
for (int i = 0; i < 200; i++) {
method2();
method3();
}
}
public void method2() {
list.add(1);
}
public void method3() {
list.remove(0);
}
}
上述list
是一个成员变量,而且它是被线程共享的,method2和method3都是引用的同一个成员变量,所以会出现线程安全问题。首先,list.add
操作是有三个步骤,第一步是获取添加元素的下标index,第二步是对指定的index位置添加元素,第三步是将index往后移。因此,当t1线程从list拿到index = 0时,t1线程的时间片用完了,t2线程获取到时间片开始运行,也从list拿到index = 0,然后添加元素到index = 0的位置,这时t2线程时间片用完了,当t1线程也会往index = 0的位置添加元素,此时index = 0位置已经有元素了,这时就会出现报错。
3.2 局部变量线程安全分析
1)局部变量是线程安全的。
2)但是局部变量引用的对象不一定
①如果该对象没有逃离方法的作用范围,则它是线程安全的
②如果该对象逃离方法的作用范围,则需要考虑线程安全
局部变量线程安全情况:
public static void test1() {
int i = 10;
i++;
}
每个线程调用 test1()
方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享。(局部变量存在于java虚拟机栈中,该数据区是线程私有的)
上述成员变量线程安全问题的解决方案:
就是将list
改为局部变量,这样就不存在共享问题了,每个线程都会创建一个新的list
对象,method2和method3
引用的也都是从method1
中传过来的list
对象。
class safeTest{
public void method1(){
ArrayList<String> arrayList = new ArrayList<>();
for (int i = 0; i < 100; i++) {
method2(arrayList);
method3(arrayList);}
}
private void method2(ArrayList arrayList) {
arrayList.add("1");
}
private void method3(ArrayList arrayList) {
arrayList.remove(0);
}
}
访问修饰符引发的一些问题:
把上述例子中method2
和method3
方法的修饰符改为public
,这时
1)如果有其它线程调用method2
和method3
,是不存在线程安全问题的,因为不同线程肯定不是同一个list对象
2)如果添加了一个子类,并且覆盖了method3或者method2
方法,就可能会出现线程安全问题:
如下这时,新的调用method3时会有一个新的线程操作list
对象,相当于list对像又被共享了!!(也就是把局部变量引用的对象暴露给了外部)
class safeTest{
public void method1(){
ArrayList<String> arrayList = new ArrayList<>();
for (int i = 0; i < 100; i++) {
method2(arrayList);
method3(arrayList);}
}
public void method2(ArrayList arrayList) {
arrayList.add("1");
}
public void method3(ArrayList arrayList) {
arrayList.remove(0);
}
}
class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
因此可以看到
private
或者final
提供安全的意义,防止子类的干扰,也就是开闭原则中的闭。
private
或者final
可以避免子类覆盖!
3.3 常见的线程安全类
-
String
-
Integer
-
StringBuffer
-
Random
-
Vector
:list的线程安全的实现 -
Hashtable
:map的线程安全的实现 -
java.util.concurrent
包下的类
注意:
这里的线程安全指的是多个线程调用它们同一个实例的某一个方法时,是线程安全的。而不同方法之间则不是线程安全的,也就是它们的每个方法是原子的,而方法之间则不是。
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
而不同方法的组合不是线程安全的:
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
不可变类的线程安全:
String
和Integer
类都是不可变的类,因为其类内部状态(内部属性)是不可改变的,因此它们的方法都是线程安全的,也就是只可以有读操作。
注意:String
有 replace
,substring
等方法返回的已经是一个新创建的对象了!