目录
线程安全问题
观察线程不安全
线程安全问题的原因
从原子性入手解决线程安全问题 ——synchronized
synchronized的使用方法
synchronized的互斥性和可重入性
死锁
死锁的三个典型情况
死锁的四个必要条件
破除死锁
线程安全问题
在前面的章节中,我们也了解到多线程为我们的程序带来了更高效的运行。但与此同时,多线程也是会带来风险的——线程安全问题。
造成线程不安全的罪魁祸首也就是 多线程的抢占式执行 ,带来的随机性。
在以单线程的形式运行的时候,代码执行的顺序是固定的,程序的结果也就是固定的。
在以多线程的形式运行的时候,此时便是多个线程之间的抢占式执行,代码的执行顺序可能性也就从一种变成无数种情况。所以就需要保证这无数种线程调度顺序的情况下,代码的执行结果都是正确的。只要有一种情况下的代码运行结果不正确,就是有bug,也就是线程不安全。
观察线程不安全
假设现在要对 counter 类中的 count 变量自增十万次,然后以两个线程来分别自增五万次,代码如下。
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class ThreadDemo12 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(counter.count);
}
}
从运行结果看出,输出结果与我们预计的结果不相符合,所以,这就是一个线程不安全的表现,一个典型的线程安全问题。下面我们分析一下为什么会出现这样的线程安全问题
对于 count++ 操作,在CPU上执行的时候,会有三条指令,对于指令,我们可以理解为是机器语言。(一个线程要执行,是会编译成很多的CPU指令的)
1. 先把内存中的值,读取到 CPU 的寄存器中。 称为 load 操作。
2. 把 CPU 寄存器里的值进行 +1 运算。 称为 add 操作。
3. 把得到的结果写入内存中。 称为 save 操作。
当程序是两个线程并发执行 count++ 的时候,也就是两组 load add save 在执行,此时不同的线程调度顺序就会产生一些结果上的差异。
(CPU中有个重要的组成部分:寄存器,寄存器可以存数据,空间更小,访问速度更快,CPU进行的运算都是针对寄存器的数据进行的;寄存器也有很多种,功能也各不相同,有参与运算的,也有保存上下文的等)
在图中定义一个时间轴,越往下就是越晚执行,由于线程之间是随机调度的,所以调度的顺序会有很多种可能性。
能得出正确结果的调度顺序
我们先列举出结果正确的情况,也就是如果两个线程之间是这样调度的,那就可以获得我们所期望的结果。
从上图可以看出,t1 线程和 t2 线程之间是有先后顺序的,要么 t1 线程先全部执行完,后执行 t2 线程,要么 t2 线程全部先执行完,后执行 t1
线程。如果我们的程序都是以这种方式去执行的话,那我们得到的结果也就是正常的,因为如果是以这种方式执行的时候,首先 t1
线程把内存中的值读取到CPU(load),然后在寄存器里进行 +1
运算(add),再接着把得到的结果再写入内存中(save),此时内存中就是修改后的值了,也就是1,然后 t2
线程再进行相同的操作,所得的结果就是2了,以此类推每个线程进行 5w 次。这样来看,所得的结果就是正常的。
无法得到正确结果的调度顺序
但是由于线程质之间是随机调度的,实际调度的顺序可不止这两种,还会有很多种可能性,可以认为是无穷种,简单列举出六种:
而这些由于线程随机调度出现的其他各种情况,都是可能导致线程安全问题的,例如这个调度顺序:
当按照这个调度顺序去执行代码的时候:
第一步:首先 t2 读取内存中的值(count = 0)到 寄存器2 中去;
第二步:t1 也读取内存中的值(count = 0)到 寄存器1 中去;
第三步:t2 在寄存器中对 count 进行 +1 运算,此时 寄存器2 中存的值为 count = 1;
第四步:t2 把寄存器中的值(count = 1)写入内存中,此时内存中 count 从 0 变为 1 了;
第五步:t1 在寄存器中对 count 进行 +1 运算,此时寄存器1 中存的值为 count = 1;
第六步:t1 把寄存器中的值(count = 1)写入内存中,此时内存中 count 从 1 变为 1;
由此我们可以看出,每个线程自增一次,按照我们的需求来说,现在的 count 应该为 2
才对,可是结果却是1,与我们预期结果就不符合了。所以两个线程,一个线程自增 5w
次,其中就会发生很多类似于这种覆盖结果的情况,所以这也就导致了两个线程各自自增 5w 次后得到结果却与我们的预期有所相差,这也就是线程安全问题了。
因此在这个代码中,两个线程之间有无数种调度方式,有且仅有两种调度方式是可以得到正确结果,所以说概率也是极小的。
而出现这种线程安全问题,本质上就是因为线程的抢占式执行,导致某个线程执行到任意一个指令的时候,线程都是有可能被调度走的,让该 CPU
来给别的线程进行执行。
线程安全问题的原因
1. 根本原因:线程之间的抢占式执行,随机调度。
2. 代码结构:多个线程同时修改同一个变量。从上述的线程不安全代码中就可以看出,进行的是两个线程同时修改 count
,就导致了线程不安全。而一个线程修改一个变量,多个线程读取同一个变量,多个线程修改多个不同的变量,就是线程安全的。3. 原子性:原子性指的是不可以再拆分的基本单位,如果修改操作不是原子性,那么出问题的概率就非常高;例如上述代码的 count++ 可以拆分成
load add save 三个步骤,所以就不是原子性,就会容易出现线程安全问题。而解决线程安全问题,最主要就是从原子性入手,把这个非原子的操作,变成原子的。
4. 内存可见性问题:当一个线程读,一个线程改,也会出现线程安全问题。(结合后续实例进行分析)
5. 指令重排序:本质上就是因为编译器优化出 bug
,编译器在保持代码逻辑不变的情况下,进行调整,从而加快程序的执行效率。但是调整就会导致代码的执行顺序改变了,线程之间的随机调度就会可能导致结果不一样,造成线程安全问题。(结合后续案例分析)以上是造成线程安全问题的典型原因,但并不是全部,一个代码是线程安全还是不安全,需要具体问题具体分析。
从原子性入手解决线程安全问题 ——synchronized
从前面的事例中,两个线程同时对一个变量进行自增,结果无数种调度方式中,只有两种是正确的。而这两种方式,在每一个线程执行的时候,都是保证了其 load
add save 作为一个整体执行,所以结果是正常的。而实际上,在无数种调度方式中,大多数是无法保证 load add save
作为一个整体去执行,往往会出现线程1在执行的时候,线程2也去执行,就会出现结果覆盖的现象,从而结果与预期并不符合。因此,也可以知道,count++这个操作并不是原子的。那么解决这个线程安全问题,我们就需要通过 “加锁” 把这个不是原子的,转成 “原子”
的。从而变为另一种情况:当一个线程在进行执行的时候,另一个线程是无法进行执行的。
使用 synchronized 关键字来进行加锁。
当我们对方法进行加锁的时候, 所得结果也就变为正确结果了。这正是因为 synchronized 使count++ 操作从 非原子性 变为 原子性
,就保证了在执行 load add save 的过程是完整的,不会有别的线程来干扰。
在程序运行过程中,进入方法就会进行加锁,出了方法就会进行解锁。如果两个线程同时尝试加锁,此时只有一个线程能获取到锁,另一个线程就进入阻塞等待,也就是进入
BLOCKED 状态。一直阻塞到加锁成功的线程释放锁,当前线程才能进行加锁。
还是针对刚刚的案例进行图解析,添加一个操作 lock 表示加锁,unlock 表示解锁。
从上图可以看出,t1 的加锁操作让 t2 想要进行执行时进入了阻塞等待,一直等到 t1 执行结束才开始执行,也就保证了 count++ 操作的原子性。
这里的加锁,保证了原子性,其实并不是让三个操作(load add
save)一次完成,也不是在这三步操作过程中不进行调度,而是让其他也想操作的线程阻塞等待了,所以说,加锁本质上是把并行(可以同时执行)变为串行(一个一个执行)。加锁后,保证了执行的准确性,但是可想而知,执行的速度也就会大打折扣。
加锁,是对 对象 进行加锁,如果两个线程针对同一个对象进行加锁,就会产生阻塞等待(锁等待 /
锁竞争);如果两个线程针对不同对象加锁,就不会阻塞等待(锁等待 / 锁竞争)
synchronized的使用方法
1. 修饰方法:进入方法就加锁,离开方法就解锁。
(这里虽然synchronized修饰的是方法,但是锁不是加到方法上的,而是加到对象上的)
1.1 修饰普通方法:锁对象为 this
1.2 修饰静态代码块:锁对象为 类对象
2.修饰代码块:手动指定锁对象
1.修饰方法
还是上面的案例:
t1 执行 add,就加上锁了,针对 counter 这个对象就上锁了,t2 执行 add 的时候,也尝试对 counter 进行加锁,但是由于
counter 已经被 t1 给占用了,因此这里的加锁操作就会阻塞。但并不代表这个对象不能用了,这个对象的其他方法和属性还是可以正常使用的。
2. 修饰代码块
this 也可以换成任意想加锁的对象。一样的道理,进入代码块就加锁,出了代码块就解锁。
总之,加锁要明确锁对象,针对哪个对象进行加锁。
而关于锁对象的规则,也很简单:
1. 如果两个线程针对同一个对象进行加锁,就会出现锁竞争 /
锁冲突,一个线程能够获取到锁(先到先得),另一个线程阻塞等待,等待到上一个线程解锁,它才能获取锁成功。2. 如果两个线程针对不同对象加锁,此时不会发生锁竞争 / 锁冲突,这两个线程都能获取到各自的锁,不会有阻塞等待。
synchronized的互斥性和可重入性
synchronized的互斥性也就可以理解为上述讲到的 “阻塞等待” 相关内容 ,一个线程上了锁,其他线程只能等待这个线程把锁释放了。
synchronized 是可重入的。
一个线程针对同一个对象连续加锁两次,如果出现问题了,就是不可重入的,如果没有问题,就是可重入的。
在上述代码中,锁对象为 this ,只要有线程调用 add ,进入 add
方法的时候,就会先加锁,紧接着遇到代码块,再次尝试加锁,但是由于第一个尝试加锁和第二个尝试加锁的线程是同一个线程,所以可以正常运行,运行结果也是正常的。
但如果不能正常运行,这时候就是出现了 死锁 状态了,也就说明锁是不可重入的。虽然这种情况在Java 中 synchronized
是可重入的,不会出现死锁状态,但C++,Python 以及 操作系统原生的锁,都是不可重入的,也就会产生死锁现象,下面就来认识一下死锁的相关内容。
死锁
死锁的出现,会导致线程无法继续执行后续的工作,程序便会出现严重的bug。
死锁的三个典型情况
1. 一个线程,连续对同一个对象,加锁两次,如果锁是不可重入锁,就会进入死锁。
2. 两个线程两把锁,线程1 和 线程2 各自针对A 和B 进行加锁,然后再尝试获取对方的锁。
这时候就会进入死锁状态了。
可以举个例子:小王(线程1)和小林(线程2)去饺子馆吃饺子,他们都喜欢蘸酱油和醋,这时候小王拿到了酱油(对A对象加锁),小林拿到了醋(对B对象加锁),然后小王说:你先把醋给我,小林说:你先把酱油给我,这时候两人互不相让,小王在等小林把醋给他,小林在等小王把酱油给他,这时候就僵住了。也就相当于进入了死锁。
public class ThreadDemo13 {
public static void main(String[] args) {
Object jiangyou = new Object();
Object cu = new Object();
Thread t1 = new Thread(()->{
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cu) {
System.out.println("小王把酱油和醋都拿到了");
}
}
});
Thread t2 = new Thread(()->{
synchronized (cu) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (jiangyou) {
System.out.println("小林把醋和酱油都拿到了");
}
}
});
t1.start();
t2.start();
}
}
通过代码演示,我们也可以看出这种情况的输出日志为空,说明没有线程拿到两把锁,也就是进入死锁了。
也可以通过 jconsole 来观察线程的状态:
可以看出,两个线程的状态都是 BLOCKED ,表示获取锁获取不到的阻塞状态;
再通过 jconsole 中的判断死锁操作可以看出,两个线程确实是进入了死锁状态;
3. 多个线程,多把锁。以案例来进行分析:
有五个哲学家进行就餐,吃一碗面条,面条放在桌子中央,桌面上只有五根筷子,每一位哲学家进行就餐就需要拿起左右两根筷子。
每位哲学家有两种状态:1.思考人生(相当于线程的阻塞状态)2.拿起筷子吃面条(相当于线程获取到锁然后进行执行),且要同时拿起左右两根筷子。由于操作系统的随机调度,这五位哲学家,随时都可能想吃面条,或者思考人生。
这个时候,出现了一种情况,同一时刻,所有哲学家都想吃面条了,且此时五位哲学家都同时拿起来左手的筷子。这就导致所有哲学家都拿不起右手的筷子,都在等待右边的哲学家放下,这时候也就再次僵住了,也就相当于进入了死锁状态。
因此,这个案例,我们也可以将五位哲学家认为是五个线程,五根筷子认为是五把锁。在某一时刻,线程1 获取锁1,线程2 获取 锁2,线程3 获取锁3,线程4
获取锁4,线程5 获取锁5,然后线程1 因为要执行某个任务需要获取锁2,但锁2 又被线程2 所占有,线程2 因为要执行某个任务需要获取锁3,但锁3
又被线程3 所占有,线程3 因为要执行某个任务需要获取锁4,但锁4 又被线程4 所占有,线程4 因为要执行某个任务需要获取锁5,但锁5 又被线程5
所占有,线程5 因为要执行某个任务需要获取锁1,但锁1 又被线程1 所占有。这样每个线程都进入了阻塞等待状态,这就进入了死锁状态了。
死锁的四个必要条件
通过上述三个典型的死锁情况,可以总结出出现死锁的四个必要条件。
1. 互斥使用:线程1 拿到锁,线程2 想拿,就需要等待。
2. 不可抢占:线程1 拿到锁之后,必须是线程1 主动释放,不能说是线程2 把锁强行获取到。
3. 请求和保持:线程1 拿到锁A之后,再尝试获取锁B,A这把锁还是保持着的,不会因为正在获取锁B 就把锁A 释放了。
4. 循环等待:线程1 尝试获取到锁A 和锁B ,线程2 尝试获取到锁B 和 锁A ,线程1 在获取B 的时候等待线程2 释放B,同时线程2
在获取锁A 的时候等待线程1 释放锁A;实际上前三个条件,对于 synchronized 这把锁来说,是固定的规则。
第四个条件,循环等待,也是唯一一个与代码结构相关的。因此对于避免死锁,也正是要从这个出发点来解决。
破除死锁
解决死锁,也就是要从循环等待这个突破口出发。
给锁编号,指定一个固定的顺序来加锁,任意线程加多把锁的时候,都让线程遵循这个顺序,此时循环等待也就破除了。
针对第二个死锁典型情况,破除死锁的办法就是给定一个顺序来进行加锁:小王和小林都应该先拿酱油再拿醋。这样死锁问题也就迎刃而解了。
//如果线程1拿到A锁,线程2拿到B锁,然后线程1又想获得B锁,线程2又想获得A锁,这样就造成了死锁,但是如果调节好顺序,也就消除了死锁的隐患了
//比如两个线程都先拿到A锁,然后再去拿B锁 核心问题就是破除循环等待
public class ThreadDemo13 {
public static void main(String[] args) {
Object jiangyou = new Object();
Object cu = new Object();
Thread t1 = new Thread(()->{
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cu) {
System.out.println("小王把酱油和醋都拿到了");
}
}
});
Thread t2 = new Thread(()->{
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cu) {
System.out.println("小林把醋和酱油都拿到了");
}
}
});
t1.start();
t2.start();
}
}
针对第三个死锁典型情况,也是一样的道理,指定固定顺序来进行加锁:每一位哲学家都先拿起左右两边编号小的那把筷子,再拿起编号大的筷子,类比于线程和锁那就是:
线程1 先获取到锁1,线程2 先获取到锁2 ,线程3 先获取到锁3,线程4 先获取到锁4,由于线程5 正在等待锁1
的释放,也就是进入了阻塞等待,就暂时没有获取到锁,(因为指定了固定顺序:先拿编号小的筷子,再拿编号大的筷子)所以这个时候线程4 也就能获取到锁5 。
紧接着线程4 两个锁都获取到了,执行完后释放锁4 和锁5,线程3 也就可以获取到锁4,线程3执行完后,释放锁4 和锁3,线程2
也就可以获取到锁3,线程2执行完后,释放锁3 和锁2,线程1也就可以获取到锁2,线程1执行完后,释放锁2 和锁1,这时候线程5 获取到锁1 了,再获取到锁
5,然后进行执行。从而执行完成,解决死锁问题!
学习网络安全技术的方法无非三种:
第一种是报网络安全专业,现在叫网络空间安全专业,主要专业课程:程序设计、计算机组成原理原理、数据结构、操作系统原理、数据库系统、 计算机网络、人工智能、自然语言处理、社会计算、网络安全法律法规、网络安全、内容安全、数字取证、机器学习,多媒体技术,信息检索、舆情分析等。
第二种是自学,就是在网上找资源、找教程,或者是想办法认识一-些大佬,抱紧大腿,不过这种方法很耗时间,而且学习没有规划,可能很长一段时间感觉自己没有进步,容易劝退。
如果你对网络安全入门感兴趣,那么你需要的话可以点击这里👉网络安全重磅福利:入门&进阶全套282G学习资源包免费分享!
第三种就是去找培训。
接下来,我会教你零基础入门快速入门上手网络安全。
网络安全入门到底是先学编程还是先学计算机基础?这是一个争议比较大的问题,有的人会建议先学编程,而有的人会建议先学计算机基础,其实这都是要学的。而且这些对学习网络安全来说非常重要。但是对于完全零基础的人来说又或者急于转行的人来说,学习编程或者计算机基础对他们来说都有一定的难度,并且花费时间太长。
第一阶段:基础准备 4周~6周
这个阶段是所有准备进入安全行业必学的部分,俗话说:基础不劳,地动山摇
第二阶段:web渗透
学习基础 时间:1周 ~ 2周:
① 了解基本概念:(SQL注入、XSS、上传、CSRF、一句话木马、等)为之后的WEB渗透测试打下基础。
② 查看一些论坛的一些Web渗透,学一学案例的思路,每一个站点都不一样,所以思路是主要的。
③ 学会提问的艺术,如果遇到不懂得要善于提问。
配置渗透环境 时间:3周 ~ 4周:
① 了解渗透测试常用的工具,例如(AWVS、SQLMAP、NMAP、BURP、中国菜刀等)。
② 下载这些工具无后门版本并且安装到计算机上。
③ 了解这些工具的使用场景,懂得基本的使用,推荐在Google上查找。
渗透实战操作 时间:约6周:
① 在网上搜索渗透实战案例,深入了解SQL注入、文件上传、解析漏洞等在实战中的使用。
② 自己搭建漏洞环境测试,推荐DWVA,SQLi-labs,Upload-labs,bWAPP。
③ 懂得渗透测试的阶段,每一个阶段需要做那些动作:例如PTES渗透测试执行标准。
④ 深入研究手工SQL注入,寻找绕过waf的方法,制作自己的脚本。
⑤ 研究文件上传的原理,如何进行截断、双重后缀欺骗(IIS、PHP)、解析漏洞利用(IIS、Nignix、Apache)等,参照:上传攻击框架。
⑥ 了解XSS形成原理和种类,在DWVA中进行实践,使用一个含有XSS漏洞的cms,安装安全狗等进行测试。
⑦ 了解一句话木马,并尝试编写过狗一句话。
⑧ 研究在Windows和Linux下的提升权限,Google关键词:提权
以上就是入门阶段
第三阶段:进阶
已经入门并且找到工作之后又该怎么进阶?详情看下图
给新手小白的入门建议:
新手入门学习最好还是从视频入手进行学习,视频的浅显易懂相比起晦涩的文字而言更容易吸收,这里我给大家准备了一套网络安全从入门到精通的视频学习资料包免费领取哦!
如果你对网络安全入门感兴趣,那么你需要的话可以点击这里👉网络安全重磅福利:入门&进阶全套282G学习资源包免费分享!
![](https://i-blog.csdnimg.cn/blog_migrate/d88b5e9cbf59541cc5a8a47a578a7a17.jpeg)