目录
一、线程间的可通信
大部分场景下,几个线程之间是需要协调配合,一起工作来完成一个任务的,这就需要线程之间可通信;
线程间的通信其实就是线程间数据的数据交换与共享;
我们知道,当几个线程共同属于一个进程时,这几个线程是共同享有OS分配的资源的
因为资源共享,这也就方便了线程之间的通信
可是,根据内容的不同,线程与线程之间也并不是所有资源都共享的,让我们来看下在JVM的内存划分中,哪些是共享的,哪些不是共享的:
【注意!】JVM的内存区域划分
-
PC保存区(保存PC值)——每个线程各有一份,每个执行流独立,指令独立
-
栈——每个线程各有一份,每个线程都有自己要处理的临时数据
虚拟机栈(保存局部变量)
本地方法栈(保存本地方法的局部变量)
-
堆——new出来的都在堆上保存,整个JVM就一份
-
方法区——类,常量,静态变量,方法字节码等,整个JVM就一份
-
运行时常量池——保存字面量,符号引用等,整个JVM就一份
所以:总结下来就是,堆、方法区和运行时常量池线程是共享的,整个JVM就只有一份
之于代码:
就是new出来的对象,加载的类,常量,静态变量,字面量等是可以共享的(当然,类那边的,前提是有该类的访问权限,线程有该对象的引用)
如下图:
二、线程安全
接上面的线程通信,线程间部分资源共享,方便了线程间通信,但是,不加处理,也可能引发线程不安全的情况
所谓线程安全,就是如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
反之,线程不安全,就是程序最终的运行结果与我们的预期不符
引入一个小栗子:
// 演示线程不安全的现象
public class Main {
// 定义一个共享的数据 —— 静态属性的方式来体现
static int r = 0;
// 定义加减的次数
static final int COUNT = 8000;
// 定义两个线程,分别对 r 进行 加法 + 减法操作
static class Add extends Thread {
@Override
public void run() {
for (int i = 0; i < COUNT; i++) {
r++;
}
}
}
static class Sub extends Thread {
@Override
public void run() {
for (int i = 0; i < COUNT; i++) {
r--;
}
}
}
public static void main(String[] args) throws InterruptedException {
Add add = new Add();
add.start();
Sub sub = new Sub();
sub.start();
add.join();
sub.join();
// 理论上,r 被加了 COUNT 次,也被减了 COUNT 次
// 所以,结果应该是 0
System.out.println(r);
}
}
单看代码,貌似是么有问题的,对同一个变量 r 加了8000次1,又减了8000次1,最后预期结果应该是0,但实际结果是这样的吗?
运行结果竟然是:
这是个啥离谱数字???我不信,再运行一次
OMG,回回运行回回不认识的离谱数字,由此看来,这个多线程并没有实现我们预期的结果欸
【小声说一句:count越大,出错的概率也越大】
1.线程不安全出现的原因
(1)开发者角度(两方面)
多个线程之间操作了同一块数据(共享数据)
至少有一个线程在修改这块共享数据
(2)系统角度(三方面)
-
原子性被破坏
🍧一条语句很可能对应多条指令;(比如刚才我们看到的 n++,其实是由三步操作组成的: 从内存把数据读到 CPU——进行数据更新——把数据写回到 CPU)
🧁线程调度是可能发生在任意时刻的,但是不会切割指令(程序员预期的是一个原子性操作,但实际执行起来保证不了原子性,所以可能会出错)
🧁比如:+-操作同时发生,当某一个操作尚未被保存,而发生了线程调度去执行另一个操作,然后再根据上下文接着执行第一个操作去保存,会发现,第二个操作的结果被覆盖掉了
🧁再看一个详细解释:
所以我们一般用加锁操作来保证原子性,下面再详细说
-
内存可见性问题
忘记了主内存和工作内存,可以先看下面的原理解释,再读这段话:
💫一个线程对数据的操作其他线程可能暂时是看不见的,缓存与缓存之间的操作变化互不可见,最后写回主内存会相互覆盖掉结果
也就是说一个线程对数据的操作,很可能另一个线程是感知不到的,甚至,某些情况下会被优化成完全看不到的结果
类似这个例子:
具体原理看这里:
【如上图,CPU为了提升数据获取速度,一般在CPU设置缓存Cache】
然后一般L1,L2是一个CPU有一套,而L3是共享一个
在JVM中:
一个线程模拟一个CPU
主存储/主内存:真实内存
工作存储/工作内存:CPU中缓存的模拟
原理解说:
线程的所有操作必须先从主内存加载到工作内存中,在工作内存中进行处理,完成最终处理后再把数据同步回主内存
这就导致,可能一个线程A修改一个共享数据,修改完后还没来得及从缓存中返回给主内存,而同时另一个线程B也对该数据操作,从主内存中取该数据时,取到的仍然是最开始的数据,线程B根本感知不到A已经对相同的该数据作出过修改这就导致,最后的最后,都写回主内存,其中一个线程的操作是被另一个线程的操作覆盖掉了的
-
代码重排序导致
我们写的程序是经过中间很多环节优化的结果,并不保证最终执行的语言和我们写的一样
程序可看作一个状态机——>留给编译器很大的空间去优化(比如a=0,for循环+1加100次,跟a=100是一样的)
举个不太恰当的问题,比如中毒喝解药,正常来说是先中毒然后喝解药解毒,但是在实际中,有可能编译器会在它的视角下来优化这个步骤,可能优化成先喝了解药(假定无毒喝解药没有任何作用)然后才中的毒
2.不需要考虑线程安全问题的情况:
(1)几个线程之间无任何共享数据;
(2)都是读操作,没有写操作
3.作为程序员如何考虑线程安全问题
(1)尽可能线程之间不作数据共享
(2)有共享操作,尽可能不去修改,而是只读操作
(3)必须共享写入操作——>学习线程安全机制,和JVM沟通,避免出现上述问题
4.常见类的线程安全问题
(1)线程不安全的:ArrayList、LinkedList、PriorityQueue、TreeMap、TreeSet、HashMap、HashSet、StringBUilder
(多线程中,不要再用线程不安全的)
ArrayList不是线程安全的——多个线程同时对ArrayList对象修改操作,结果会出错
(2)线程安全的:Vectot 、Stack 、Dictionary、 StringBuffer
(但这几个类都是Java设计失败的产品,不要用它们)
(3)常见的违反原子性的场景
read-write场景(arr[i] = 4;i ++)
check-update场景( if( ) { } )
那么,我们作为开发人员,要怎么解决线程安全问题呢?答案是lock
三、synchronize锁
1 锁(Lock)的理解
锁有两种状态:
locked (锁上,false) & unlocked(打开,true)
如果锁是打开状态,A线程抢到了锁,A就可以把锁锁上,锁上以后安心执行A的锁内保护的代码,对于其他相同锁的线程B,B得到的就会是锁被锁上了的状态,所以,B是不能运行的,B会加入锁的阻塞队列中,等待锁成为打开状态后,再就绪
所以,最基本的原理,通过加锁,可以保证原子性
所以,如果两个线程A和B都是要操作同一个共享数据,我们可以通过加锁的操作,保证互斥性,就是A操作这个数据时,B是不能操作的(因为A把锁锁上了)
重点注意:前提是两个或多个线程竞争的是同一把锁,没抢到锁的线程才会阻塞等待,保证互斥性,如果不是同一把锁,是互不影响的
2.synchronize锁——同步锁
👻👻👻语法:
(1)修饰方法(普通、静态方法都可)——>同步方法(修饰普通方法,相当于对当前对象加锁,修饰静态方法被视为对静态方法所在的类加锁)
(2)修饰代码块——>同步代码块 synchronize(引用)
对于普通方法
写法一:
public synchronized void add(){
}
写法二:修饰代码块
public void add(){
synchronized(this){
}
}
对于静态方法
写法一:
public static synchronized void add(){
}
写法二:修饰代码块
public static void add(){
synchronized(类名.class){
}
}
👻👻👻原理看这里:
尝试加锁的内部操作:(整个尝试加锁的操作已经被JVM保证了原子性)
如果锁没有被锁上,
if(locked == false){
当前线程得到该锁,把锁锁上,然后执行自己的代码
lock = true;
return
}else{
// 如果锁已经被锁上了
Queue<线程> 该锁的阻塞队列 queue=……
queue.add(Thread.currentThread());加入该锁的阻塞队列,等待锁被打开后再运行
Thread.currentThread().state = 阻塞;
Thread.yield();相当于让出CPU
}
执行完代码块后释放锁的内部操作:
(前提:这个过程由系统保证了原子性)
释放锁:lock = false
从等待锁的阻塞队列中选一个线程出来回复CPU
Thread t = queue.poll();
t.state = 就绪
👻👻👻synchronized锁的特性结论:
(1)互斥
当多个线程都有加锁操作并且申请同一把锁时,会形成互斥现象
加锁 代码s(临界区) 解锁
临界区代码互斥着进行,现象就是属于同一个锁的两个线程不会同时进行,谁抢到锁,谁就执行,直到执行完后锁被打开了,才会换下一个线程
(2)保证内存可见性
synchronized 的工作过程:
获得互斥锁——从主内存拷贝变量的最新副本到工作的内存—— 执行代码——将更改后的共享变量的值刷新到主内存——释放互斥锁
所以 synchronized 也能保证内存可见性
(3)可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
死锁是啥,不可重入是啥呢?就是我第一次拿到锁办完事后没有解锁,直接也进入阻塞队列中等待第二次拿锁,而锁一直没开,导致所有线程都在傻等着,这就是死锁,死锁导致我自己也不可重入没办法去开锁
Java 中的 synchronized 是 可重入锁, 因此没有上面的问题
👻👻👻栗子来啦:
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("我是张三");
}
}
}
}
static class MyThread2 extends Thread {
@Override
public void run() {
synchronized (lock) {
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();
}
}
结果:
先打印完10000个”张三“后,再去打印10000个”李四“(当然顺序不一定,也可能先打印完10000个李四,再打印10000个张三,关键在于谁先抢到锁)
如果不加锁:
张三和李四交替出现,两个线程类似同时进行
👻👻👻习题大测试
好了,说了这么久,来检验下自己到底懂了没吧?
前情小提示:
互斥的必要条件:两个线程都有加锁操作,并且锁的是同一把锁
看下面这段代码,分别来分析下
public class SomeClass {
public synchronized void m1() {
}
public synchronized void m2() {
}
public void m3() {
}
public void m4() {
synchronized (this) {
}
}
public void m5() {
synchronized (SomeClass.class) {
}
}
Object o1 = new Object();
public void m6() {
synchronized (o1) {
}
}
static Object o2 = new Object();
public void m7() {
synchronized (o2) {
}
}
}
public class Main {
public static void main(String[] args) {
SomeClass s1 = new SomeClass();
SomeClass s2 = new SomeClass();
SomeClass s3 = new SomeClass();
s3 = s1;
// 试分析下列方法的调用是否互斥,直接在表格中分析叭
s1.m1();
s2.m1();
// ……
}
}
最后几个关于m7的易错,注意一下
👻👻👻杂谈
- JVM把每个对象都当作锁使用
- synchronized(null){} 如果是null,会有空指针异常
- 加锁粒度越细,并发的可能性越高
- 正确加锁得到互斥现象——保证了原子性
-
synchronized在有限程度上可以保证内存的可见性
-
synchronized也可以给代码重排序增加一定的约束