线程学习(2)线程创建,等待,安全,synchronized(二)+https://developer.aliyun.com/article/1413578
五.线程安全问题
有些代码如果只是一个线程单独去执行,执行结果是完全正确的
但是,同样的代码,如果使用多个线程同时去执行,执行结果就可能产生问题,这种就是"线程安全问题"/"线程不安全"
比如我们要对一个数使其自增1w,如果只使用一个线程来解决,其结果一定正确
public static void main(String[] args) { // 在主线程中单独执行 int cnt = 0; for (int i = 0; i < 10000; i++) { cnt++; } System.out.println(cnt);// 输出10000 }
如果使用两个线程实现这个目标,则应该是一个线程自增5000次,加起来一共自增1w次
private static int cnt = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { cnt++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { cnt++; } }); // 线程开启 t1.start(); t2.start(); // 让主线程等待两个线程结束 t1.join(); t2.join(); // 输出打印 System.out.println(cnt);// 输出7351 }
最后的打印结果是一个莫名其妙的数,不是我们想的1w,如果继续重复尝试,发现每次打印的结果还都不相同 ,程序出现bug了,这种问题就是在并发编程中常遇到的线程安全问题
为什么会出现这种问题呢,此时就要深入底层去看下cnt++这个操作是如何实现的
cnt++的实现在底层中分为三步
- load 把数据从内存中 读取到cpu寄存器中
- add 把寄存器中的数据+1
- save 把寄存器中的数据,保存到内存之中
站在cpu的角度,cnt++这个操作分别对应着三条cpu指令,是由这三条指令实现的~
如果使用多线程来执行上述代码,由于线程之间的调度顺序是随机的,就会导致在一些调度顺序下发生错误,下面来看都有哪些可能的调度顺序
可以看出,调度顺序的种类其实是无数种!!!一是调度操作的逻辑顺序,二是每个线程执行多少次我们并不知道,在图中,只有前两种的调度顺序才能达到我们想要的结果,下面以一个反例来验证其他顺序的错误
由于线程调度的随机性,也就说上述调度顺序也是随机的,所以最终产生的结果也是随机的(但是最终的结果一定比1w小,因为只有前两种调度顺序才能实现数字的正确增加)
那一定比5000大么,这也是不一定的,如果在t1自增一次的过程中,t2自增了两次,一共消耗了三次自增,但实际上只自增了一次,如果这种逻辑顺序占多数,就有可能出现<5000的情况
产生线程安全问题的原因
- 操作系统中,线程的调度顺序是随机的(抢占式执行) 罪魁祸首
- 多个线程,针对同一个变量进行修改(上述例子就是)
- 修改操作不是原子的,cnt++这个操作是分三步执行的,不是原子的。什么是原子的呢》比如存在一个cpu指令能同时完成cnt++的三个操作
- 内存可见性问题
- 指令重排序问题
说明:
对于第二种原因,改变一些描述就不是线程安全问题了
- 一个线程,针对同一个变量进行修改 ok
- 多个线程,针对不同的变量进行修改 ok
- 多个线程,针对不同的变量进行读取 ok
通过加锁就能解决上述问题
六.锁 synchronized
1.基本概念
如何给Java的代码进行加锁呢?其中最常用的方法是通过synchronized关键字(最好还是掌握下他的发音和含义)
synchronnized在使用的时候需要搭配{}来使用,进了{}就相当于"加锁",出了{}就是"解锁",在已经加锁的状态下,如果另一个线程也尝试同样加这个锁,就会发生"锁竞争"/"锁冲突",后一个线程就会阻塞等待
加锁,我们要明确是给谁加锁,也就是要对具体的对象进行加锁,只有当两个线程针对同一个对象进行加锁,才会发生冲突,针对不同的对象加锁,就不会发生冲突(可以把加锁理解为确立男女朋友关系,一旦确立(加锁),就不允许其他人再进入了,除非原先的关系破裂(解锁),不能脚踏两只船~~~)
代码实现:
// 锁竞争的对象 Object locker = new Object(); Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { // 使用synchronized关键字进行加锁 synchronized(locker) { cnt++; } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { // 使用synchronized关键字进行加锁 synchronized(locker) { cnt++; } } });
在这个代码中,我们先是创建了一个用于"加锁"的对象locker,接着进行加锁,如何加锁呢?根据上述引发线程安全的2"多个线程,针对同一个变量进行修改",我们要限制的是两个线程不能同时对同一个变量进行修改,所以应该加锁的操作是"cnt++",使用synchronized(locker){}对其进行加锁
这种情况是我们上述所说的会引发线程安全问题的一种调度顺序,下面看看加锁是如何解决这个问题的
对象存在的意义有且仅有一个,当多个线程针对同一个对象进行加锁的时候,就会发生锁冲突,一个线程拿到锁,就继续执行代码,而另一个线程没拿到锁,就会处于阻塞状态。直到另一个线程释放锁,才能继续执行剩余代码~
这样做实际上是把"并发执行"转换为"串行执行",这样就避免了操作之间的穿插,导致错误的出现
注意:必须是多个线程针对同一个对象进行加锁,如果是不同的对象就不会发生锁冲突,也就不会出现线程阻塞
// 锁竞争的对象 Object locker = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { synchronized(locker) { cnt++; } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { // 此时两个线程针对的是不同的对象进行加锁 不发生锁冲突 synchronized(locker2) { cnt++; } } });
还是那句话,锁的对象是谁不重要,只要锁的是同一个对象就一定会引发锁冲突!!!
2.synchronized对方法进行加锁
除了对对象加锁,synchronized还可以对方法加锁
下面先修改一下我们的代码:
class Counter { public static int cnt; // 将cnt++这个操作放到一个方法内部 public void increment() { cnt++; } } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { counter.increment(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { counter.increment(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.cnt); }
此时继续执行代码,发现结果仍然是随机值,原因在于还是出现了"两个线程同时针对同一个变量进行修改"这样的操作,解决方法就是进行加锁,此时我们可以对increment()这个方法进行加锁
synchronized public void increment() { cnt++; }
加锁之后,只能有一个线程拥有increment(),另一个线程想要使用的话只能等前一个线程释放锁之后才能使用
上述对方法加锁的方式还有一个等价方式
public void increment2() { // 这其实是对"实例方法"进行加锁的 // 实例方法的实现取决于实例化的对象 也就是本质上是对调用这个方法的对象进行加锁 // 让调用方法的对象拥有这个锁 synchronized (this) { cnt++; } }
同样的,synchronized还可以对"类方法进行加锁",对类方法加锁本质上就是对类对象进行加锁
// 类方法 synchronized public static void increment3() { cnt++; } public static void increment4() { // 这里利用到了反射 synchronized (Counter.class) { cnt++; } }
说明:类对象在一个Java文件中是唯一的,类对象中包含很多信息,比如类的属性,方法,继承关系,实现接口等等,类对象可以通过反射的方式获取
3.对象的"锁属性"是存在于对象头中的
我们知道一旦一个对象被synchronized修饰就代表该对象被上锁了,也就是说synchronized改变了对象的原有属性,而这个决定对象是否被"锁"的属性存在于"对象头"之中
对于一个对象来说,除了其自定义的一些属性,还有一些系统为其分配的属性,这些属性的集合被称为"对象头",对象头中,就有属性用于表示对象是否被锁,以下是对象数据的组成
4. synchronized的重要特性-可重入性
要了解可重入性,先了解什么是可重入锁,所谓的可重入锁就是指:
一个线程能够对同一个对象连续加锁两次,不会出现死锁,就是可重入。不满足,就是不可重入;
而被synchronized修饰的对象都具有可重入性,再简单来说,可重入性就是指一个线程再持有一个对象的锁之后,还可以再次对该对象加锁,举一个简单的例子
class MyClass { // 创建两个静态方法 synchronized public static void methodA() { System.out.println("这是methodA"); methodB(); } synchronized public static void methodB() { System.out.println("这是methodB"); } public static void main(String[] args) { // 调用类方法 此时synchronized是对类对象进行加锁 MyClass.methodA(); } }
再这个例子中,主线程中我们调用了methodA,因为 methodA是被synchronized修饰的,此时主线程就持有了MyClass类对象类的锁,紧接着进入methodB,因为methodB也是synchronized修饰的,所以即便在methodA内部调用methodB,主线程也会再次获取MyClass类对象类的锁,会直接获取成功
今天线程的学习就到这里,敬请期待后续章节