1.共享带来的问题
1.1.线程安全问题
例如:
两个线程对初始值为0的静态变量一个做自增,一个做自减,各做5000次,结果是0吗?
@Slf4j
public class TestThread {
//静态共享变量
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();
log.info("{}", counter);
}
}
可以多运行几次,每次运行结果都不一样,偶尔会出现结果为"0"的正常现象;
1.2.问题分析
以上的结果可能是正数、负数、零.为什么呢?因为Java中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析;
①.例如对于"i++"而言(i为静态变量),实际会产生如下的JVM字节码指令;
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
②.而对应"i–"也是类似;
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
③.而java的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换;
- 如果是单线程以上8行代码是顺序执行(不会交错)没有问题;
- 但多线程下这8行代码可能交错运行;
出现负数的情况:
出现正数的情况:
1.3.临界区(Critical Section)
1>.一个程序运行多个线程本身是没有问题的;
2>.问题出在多个线程访问共享资源;
①.多个线程读共享资源其实也没有问题;
②.但是在多个线程对共享资源读写操作时发生指令交错,就会出现问题;
3>.一段代码块内如果存在对共享资源的多线程读写操作,那么就称这段代码块为临界区;
public class TestThread2 {
static int counter = 0;
static void increment()
// 临界区
{
//操作共享资源(读和写)
counter++;
}
static void decrement()
// 临界区
{
//操作共享资源(读和写)
counter--;
}
}
1.4.竞态条件(Race Condition)
多个线程在临界区内执行(即多个线程执行了临界区代码),由于代码的执行序列不同(字节码指令交错执行)而导致结果无法预测,这样的情况就称之为发生了竞态条件;
2.Synchronized解决方案
1>.为了避免临界区的竞态条件发生,有多种手段可以达到目的:
①.阻塞式的解决方案: synchronized,Lock;
②.非阻塞式的解决方案: 原子变量;
2.1.Synchronized简介
1>.Synchronized,即俗称的"对象锁",它采用互斥的方式
让同一时刻至多只有一个线程能持有"对象锁"(独占锁)
,其它线程再想获取这个"对象锁"时就会被阻塞住.这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
;
注意: 虽然Java中互斥和同步都可以采用synchronized关键字来完成,但它们还是有区别的:
①.互斥是保证临界区的竞态条件发生时,同一时刻只能有一个线程执行临界区代码;
②.同步是由于线程执行的先后顺序不同,需要一个线程等待其它线程运行到某个点(才能继续运行);
2.2.Synchronized基本使用
2.2.1.语法
synchronized(对象) //线程1(running),线程2(blocked)
{
临界区(当临界区代码执行完毕,释放对象锁,唤醒处于阻塞状态中的线程,线程才有机会获取到对象锁)
}
2.2.2.案例
@Slf4j
public class TestThread3 {
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.info("{}", counter);
}
}
无论执行多少次,共享变量的值最终都是"0"!!!
分析:
可以做这样的类比
①.synchronized(对象)中的对象,可以想象为一个房间(room),有唯一入口(门),房间每次只能进入一人,线程t1,t2想象成两个人;
②.当线程t1执行到synchronized(room)时就好比t1进入了这个房间,并锁住了门拿走了钥匙,在门内执行"count++"代码;
③.这时候如果t2也运行到了synchronized(room),它发现门被锁住了,只能在门外等待,发生了上下文切换,线程被阻塞住了;
④.这中间即使线程t1的cpu时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,线程t1仍拿着钥匙,线程t2还在阻塞状态进不来,只有下次轮到t1自己再次获得时间片时才能开门进入;
⑤.当线程t1执行完synchronized{}块内的代码,这时候才会从(room)房间出来并解开门上的锁,然后唤醒线程t2,把钥匙给它.线程t2这时才可以进入(room)房间,锁住了门拿上钥匙,执行它的"count–"代码;
当拥有对象锁的线程执行完synchronized{}代码代码块中的代码后,释放对象锁,然后唤醒所有的处于阻塞状态的线程,最后多个线程竞争CPU时间片,然后某个分配到CPU时间片的线程获取到对象锁,执行synchronized{}代码块中的代码;
如图:
思考:
①.synchronized实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断;
②.为了加深理解,请思考下面的问题:
- 如果把synchronized(obj)放在for循环的外面,如何理解?-- 原子性
- 如果t1 synchronized(obj1),而t2 synchronized(obj2)会怎样运作?-- 锁对象
- 如果t1 synchronized(obj),而t2没有加会怎么样?如何理解?-- 锁对象
2.2.3.锁对象面向对象改造
1>.把需要保护的共享变量放入一个类(对象)中,在类的内部(操作方法)对共享资源的操作进行保护
@Slf4j
public class TestThread3 {
public static 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();
log.info("count: {}", room.get());
}
}
class Room {
int value = 0;
public void increment() {
//对象锁为当前对象本身
synchronized (this) {
value++;
}
}
public void decrement() {
//对象锁为当前对象本身
synchronized (this) {
value--;
}
}
public int get() {
//对象锁被当前对象本身
synchronized (this) {
return value;
}
}
}
2.3.成员方法上的Synchronized
①.Synchronized关键字加在成员方法(即非static方法)上,对应的对象锁就是当前类的this对象(即当前对象);
②.Synchronized关键字加在static方法上,对应的对象锁就是当前类的字节码对象(类名.class);
1>.示例1:
class Test{
public synchronized void test() {
}
}
//等价于
class Test{
public void test() {
synchronized(this) {
//...
}
}
}
2>.示例2
class Test{
public synchronized static void test() {
}
}
//等价于
class Test{
public static void test() {
synchronized(Test.class) {
//...
}
}
}
2.4.变量的线程安全分析
2.4.1.成员变量和静态变量是否线程安全?
1>.如果它们没有被共享,则线程安全;
2>.如果它们被共享了(在多个方法中被使用),根据它们的状态是否能够改变,又分两种情况:
①.如果只有读操作,则线程安全;
②.如果有读写操作(值会发生改变),则这段代码是临界区,需要考虑线程安全;
2.4.2.局部变量是否线程安全?
1>.局部变量是线程安全的;
2>.局部变量引用的对象则未必安全:
①.如果该对象没有逃离方法的作用范围,它是线程安全的;
②.如果该对象逃离了方法的作用范围,需要考虑线程安全;
2.4.3.局部变量线程安全分析
1>.示例
public static void test1() {
int i = 10;
i++;
}
分析:
①.每个线程调用 test1()方法时,在每个线程的栈帧内存中都会创建/生成一份各自的局部变量i,是线程私有的,因此不存在共享;
public static void test1(); descriptor: ()V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=0 0: bipush 10 2: istore_0 3: iinc 0, 1 6: return LineNumberTable: line 10: 0 line 11: 3 line 12: 6 LocalVariableTable: Start Length Slot Name Signature 3 4 0 i I
局部变量的引用稍有不同(这里就不分析了)!
2.4.4.成员变量线程安全分析
1>.示例:
class ThreadUnsafe {
//list对象定义在成员变量位置,会在堆内存中创建实例,只有一份,可以被所有线程共享
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);
}
}
public class TestThread{
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();
}
}
}
执行之后其中一种情况是,如果线程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)
分析:
①.无论哪个线程中的method2(),引用的都是同一个对象中的list成员变量;
②.method3()与method2()分析相同;
2>.将list修改为局部变量
class ThreadSafe {
public final void method1(int loopNumber) {
//list变量定义在方法内部,每个调用该方法的线程都会创建一个(新的)不同的对象实例,该对象实例属于线程私有
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
private void method3(ArrayList<String> list) {
list.remove(0);
}
}
再次执行就不会出现上述的问题了;
分析:
①.此时list是局部变量,每个线程调用时会创建其不同实例,没有共享;
②.而method2()的参数是从method1()中传递过来的,与method1()中引用同一个对象;
③.method3()的参数分析与method2()相同;
3>.方法修饰符带来的思考: 如果把method2()和method3()的方法修改为public会不会带来线程安全问题?
①.情况1: 有其它线程调用method2()和method3();
- 不会有线程安全问题;
②.情况2:
在情况1的基础上
,为ThreadSafe类添加子类,子类覆盖method2()或method3()方法,即: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); } } class ThreadSafeSubClass extends ThreadSafe{ @Override public void method3(ArrayList<String> list) { new Thread(() -> { //这里的list对象是之前的线程创建好的,也就是说之前> 线程创建的list对象被多个线程共享了(局部变量的引用暴> > 露给了其他线程),最终会出现线程安全问题; list.remove(0); }).start(); } }
结论:
方法的访问修饰符(private)在一定程度上可以保护方法的线程安全,因为它限制了子类不能够覆盖对应的方法(即方法的行为不被子类影响),因此子类中的同名方法和父类中的并不是同一个;
2.5.常见的线程安全类
String
Integer
StringBuffer
Random
Vector
Hashtable
java.util.concurrent包下的类
这里说它们是线程安全的是指: 多个线程调用它们同一个实例的某个方法时,是线程安全的.
也可以理解为: 它们的每个方法(单独使用)是原子的,但是注意它们多个方法的组合使用可不是原子的!
例如:
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
2.6.不可变类的线程安全性
一个类,其内部的属性只能读取,而不能修改,这样的类被称为不可变类;
1>.String、Integer等都是不可变类,因为其内部的状态(/属性)不可以改变,因此它们的方法都是线程安全的;
2.8.线程安全分析
1>.案例1
public abstract class Test {
public void bar() {
// 是否安全?
// 答案:线程不安全,因为局部变量的引用会通过方法暴露给其他线程
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 局部变量的作用域发生了逃逸
foo(sdf);
}
public abstract foo(SimpleDateFormat sdf);
public static void main(String[] args) {
new Test().bar();
}
}
子类方法:
public void foo(SimpleDateFormat sdf) {
String dateStr = "1999-10-11 00:00:00";
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
其中foo()的行为是不确定的,可能导致不安全的发生,因此也被称之为外星方法;