(一)我们要从什么角度来解决线程不安全问题
在上一篇文章中,我们说了什么是线程不安全,以及什么情况下会发生线程不安全问题,总而言之就是三点:
1)线程在cpu上是随即调度,抢占式执行的
2)多个线程同时修改共享的变量
3)修改的操作不是原子的
那知道了线程不安全如何产生的,我们就针对上述三点来解决线程不安全问题
(二)synchronized关键字
上述第一点线程在cpu上是随即调度,抢占式执行的,这个我们无法进行修改,因为这个涉及到操作系统层面,而第二点,需要我们修改自己的代码结构,这个需要结合实际来处理。相对前面两点有没有一种相对万能的方法?
我们只需要把操作变为原子的不就好了,或者说在执行这个操作的时候,其他线程无法插在中间执行(注意是其他线程不能插在中间执行,而不是我们无法被cpu调度走,事实上,即使我们加了锁,也不妨碍cpu把我们调度走)。这时候就需要加锁
涉及到锁,那就必须要谈到我们的synchronized了
synchronized的特性:
1.synchronized是互斥锁
某个线程执行到被synchronized加锁的对象时。如果有其他线程也执行到synchronized时,就会阻塞等待
此时进入synchronized的代码块就相当于加锁,等退出synchronized代码块就会解锁
public static void main(String[] args) throws InterruptedException {
Object lock=new Object();
Thread bro=new Thread(()->{
synchronized (lock){
while(true){
System.out.println("我占着茅坑不拉屎");
}
}
});
Thread bro2=new Thread(()->{
synchronized (lock){
while(true){
System.out.println("求你了快出来");
}
}
});
bro.start();
Thread.sleep(1000);
bro2.start();
}
这就跟我们上厕所是一样的,如果厕所里有人,那你也得阻塞等待 ,就像代码一样,如果我一直不释放锁,那另一个线程也无法执行
说了这么多次阻塞等待,那他背后是怎么实现的呢?
针对每一把锁,操作系统都会给我们设置一个等待队列,如果有线程拿到了这把锁,其他线程再尝试加锁就会进入这个等待队列,等到拿到了锁的线程释放锁,再由操作系统随机唤醒一个线程来拿到这把锁。
1) 这里有两点需要我们注意,一个线程释放了锁,另一个线程不是立刻就能获得这把锁,需要等待操作系统唤醒,而且释放了这个锁的线程如果刚释放就再次对这个对象加锁,大概率还是这个线程拿到锁(这里涉及到自旋锁和挂起等待锁等后续说到乐观锁悲观锁再说)。
2)假设有ABC三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C 都在阻塞队列中排队等待.但是当A释放锁之后,虽然B⽐C先来的,但是B不⼀定就能获取到锁, ⽽是和C重新竞争,并不遵守先来后到的规则.(这里又涉及到公平锁和非公平锁,等讲到reentrantlock是会说)
2.synchronized是可重入锁
一个线程已经对一个对象进行加锁,如果可以在锁内重复进行加锁操作且不发生错误,就称这个锁是可重入的,这里我们用代码来展示一下:
public static void main(String[] args) throws InterruptedException {
Object lock=new Object();
Thread t1=new Thread(()->{
synchronized (lock){
synchronized (lock){
System.out.println("我要重复加锁");
}
}
});
t1.start();
}
这里就会产生个疑问:哎?那我可重入锁有什么用啊,看代码也没啥用啊
确实,但是可重入锁可以很好的避免死锁问题
我们用一个例子来说明什么是死锁
按照之前对于锁的设定,第⼆次加锁的时候,就会阻塞等待.直到第⼀次的锁被释放,才能获取到第二 个锁.但是释放第⼀个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想⼲了,也就⽆法进 ⾏解锁操作.这时候就会死锁.
synchronized可以修饰什么:
1.修饰普通方法
public synchronized void tmi(){
System.out.println("111");
}
2.修饰静态方法
public synchronized static void tmi(){
System.out.println("111");
}
3.修饰代码块
public static void main(String[] args) throws InterruptedException {
Object lock=new Object();
Thread t1=new Thread(()->{
synchronized (lock){
}
});
}
在修饰代码块时,我们也可以对当前对象进行加锁--this
只有两个线程对同一对象进行加锁才会阻塞等待
(三)JAVA标准库中线程安全的类
在数据结构时期我们学习了不少类,但大多数都是线程不安全的,因为都涉及到修改变量,但是却并没加锁,比如:
• ArrayList
• LinkedList
• HashMap
• TreeMap
• HashSet
• TreeSet
• StringBuilder
但也有内置锁,线程安全的类
• Vector
• HashTable
• ConcurrentHashMap
• StringBuffer
上述线程安全的类都在一些关键的方法中进行了加锁
(四)volatile关键字
在上一篇博客中我们说volatile可以保证内存可见性可防止指令重排序,那现在我们就来聊一下volatile关键字
volatile保证内存可见性
之前我们说内存分为主存和“工作内存”,之所以有内存可见性问题是因为,我们的代码会从“工作内存”中找到所需要的数据拿来使用。可是“工作内存”中的数据不一定是主存中最新的数据。
在知道为什么会有内存可见性问题,我们就能知道volatile大概是怎么解决问题的了
1)在读volatile修饰的变量时,我们会从主内存中取到volatile修饰的变量,并且将它写到工作内存中,再从工作内存中读取变量
2)在写volatile修饰的变量时,我们会先将变量写入到工作内存中,在将工作内存的值刷新到主内存中
此时我们就会想,那以前都是直接访问工作内存,这下还会涉及到主内存,会不会降低效率?
当然会,但也是没办法的事情
可能光说不够直观,上代码!
public class text {
public int flag=0;
public static void main(String[] args) throws InterruptedException {
Object lock=new Object();
text wd=new text();
Thread t1=new Thread(()->{
while(wd.flag==0){
}
});
Thread t2=new Thread(()->{
Scanner s1=new Scanner(System.in);
System.out.println("更改flag的值让循环停下");
wd.flag=s1.nextInt();
});
t1.start();
t2.start();
}
}
此时代码并没有停下,这就是发生了内存可见性问题
如果把flag用volatile关键字修饰,就可以避免问题
volatile防止指令重排序
编译器和CPU为了优化执行效率,可能会对代码执行顺序进行调整(只要不影响单线程下的语义)。 但volatile修饰的变量,在其前后的操作都不能随意重排,以确保多线程环境下的有序性。
(五)wait和notify
wait()
首先我们先来了解一下wait方法都帮我们做了什么
1.释放锁 2.让线程进入阻塞等待 3.等待唤醒,重新竞争锁
注意既然我们要释放锁,那就规定了,如果我们没有加锁就调用了wait方法,会抛出一个异常
如何唤醒wait中的线程
1.通过notify方法 2.本身是带有超时时间的wait方法 3.通过其他线程调用interrupted方法抛出异常,捕获异常来唤醒
我们先来看一下wait方法如何使用
public class text {
public static void main(String[] args) throws InterruptedException {
Object lo=new Object();
Thread t1=new Thread(()->{
synchronized (lo){
System.out.println("before wait");
try {
lo.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("after wait");
}
});
}
}
这样在执⾏到object.wait()之后就⼀直等待下去,那么程序肯定不能⼀直这么等待下去了。这个时候就 需要使⽤到了另外⼀个⽅法唤醒的⽅法notify()。
notify()
notify⽅法是唤醒等待的线程.
• ⽅法notify()也要在同步⽅法或同步块中调⽤,该⽅法是⽤来通知那些可能等待该对象的对象锁的其 它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
• 如果有多个线程等待,则有线程调度器随机挑选出⼀个呈wait状态的线程。(并没有"先来后到")
• 在notify()⽅法后,当前线程不会⻢上释放该对象锁,要等到执⾏notify()⽅法的线程将程序执⾏ 完,也就是退出同步代码块之后才会释放对象锁。
notifyAll()
从名字可以看出,这个方法一次性可以同时唤醒所有对这个对象阻塞的线程,再让这些线程去竞争,看谁能拿到锁
说完了这些,我们再额外说一下wait和sleep的区别和相同点:
相同点:都可以让线程阻塞一段时间
不同点:sleep是Thread中的方法,而wait是Object中的方法
sleep不需要搭配锁来使用,wait必须在锁中使用
(六)多线程场景
1.单例模式
先说一下什么是单例模式:一个类在程序中,只能有一个实例
常见的单例模式有两种实现方式:懒汉和饿汉
这里我们先附上饿汉方式实现单例模式的代码
public class text {
public static text hun=new text();
private text() {
}
public text gethun(){
return hun;
}
}
在上述代码中,我们将构造方法变成私有,这样其他类就不能通过构造方法创建这个对象。而且我们说的唯一的一个实例就像一个类对象,一个类只能有一个,所以保证了我们的“单例”
而之所以叫饿汉模式实现,是因为我们在创建好这个类的时候,这个对象就已经被实例化出来了,就像很久没吃过饭的人一样(起的名还是很形象的)。
注意:因为我们饿汉实现的单例模型,没有写操作,所以本身就是线程安全的,但是因为他无论我们是否想创建这个对象,他都会实例化,所以也会浪费一定的资源
那接下来我们要将通过懒汉的方式实现单例模式
(这里先说一句个人观点,在我看来懒汉和饿汉各有各的好处,并没有说懒汉通过我们的处理变成线程安全后就一定会比饿汉好)
这里我们附上懒汉方式实现单例模式的代码
public class text {
public static text lazy=null;
private text() {
}
public text getLazy(){
if(lazy==null)
lazy=new text();
return lazy;
}
}
这里我们可以看到,在有这个类的时候,并没有实例化这个对象,只有当我们调用了getlazy()方法后,系统才会给我们实例化一个对象
在饿汉实现单例模式时我们说,饿汉不涉及到写操作,所以是线程安全的,那我们此时再看懒汉模式,我们在调用getlazy()方法时,新实例化一个对象并且写入给我们的lazy,那如果此时又有一个线程同时实例化这个对象,是不是就会引发线程不安全,此时我们就需要进行加锁处理,来保证线程安全。
那修改之后的代码如下
public class text {
public static text lazy=null;
private text() {
}
public text getLazy(){
synchronized (text.class){
if(lazy==null)
lazy=new text();
}
return lazy;
}
}
此时加上锁之后,就可以保证我们线程是安全的,但这时又会有一个问题,如果我们lazy已经实例化了一个对象,那我每次调用getlazy()方法我还需要重复的加锁解锁,那肯定会影响我们的效率,那这时我们就需要在锁的外面再判断一次,lazy是否为空,如果为空,那我再加锁,如果不为空,我就不需要加锁,省去这部分的开销。
修改之后代码如下:
public class text {
public static text lazy=null;
private text() {
}
public text getLazy(){
if (lazy==null) {
synchronized (text.class) {
if (lazy == null)
lazy = new text();
}
}
return lazy;
}
}
讲到这里,就差最后一个点了,之前我们总说,volatile可以保证内存可见性,那在上述代码中,如果主存不能及时的知道我们new了一个对象,那么就会发生线程不安全问题,所以我们需要加上volatile关键字
完善后的代码如下:
public class text {
public volatile static text lazy=null;
private text() {
}
public text getLazy(){
if (lazy==null) {
synchronized (text.class) {
if (lazy == null)
lazy = new text();
}
}
return lazy;
}
}
那么综上,就是我们单例模式的全部优化过程
2.阻塞队列
阻塞队列,从名字上可以知道是一个队列,既然是队列那就满足先进先出的规律
但是与普通的队列不同的是,阻塞队列是线程安全的,那他是什么原理呢?
因为只有对变量进行修改(也就是写操作)会涉及的线程安全,所以阻塞队列就是对入队列和出队列进行加锁处理。
当这个队列满了时,其他线程如果还想入队列就要阻塞等待一个其他线程,从队列中取出元素
当队列为空时,其他线程如果还想出队列,就要阻塞等待一个其他线程,向队列中添加元素。
生产者消费者模型
通过阻塞队列,我们可以实现这个模型。
这时就有人会提出疑问?那这个模型有啥用啊,什么情况下我们可以用到啊
⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。 ⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤ 等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取.
我们可以想象一个场景:有一个大服务器负责接收客户端请求,一个小服务器来提供服务,当大服务器需求量很多时,如果全部都丢给这个小服务器,那服务器很容易就挂了,而小服务器挂了,那大服务器大概率也会挂,因为他们两个直接相连,这时如果我们用阻塞队列,就可以很好的解决
我们可以让大服务器把所有请求都丢到阻塞队列中,再让小服务器们从阻塞队列中取,这样即使挂了一个小服务器,大服务器也可以正常工作。
说了这么多理论知识,那现在就来用java给我们内置的阻塞队列,来实现生产者消费模型:
public class text {
public static void main(String[] args) {
BlockingQueue<Integer> blockingQueue=new LinkedBlockingQueue<>();
Thread customer=new Thread(()->{
while (true){
try {
System.out.println("开始消费"+blockingQueue.take());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread producer=new Thread(()->{
Random random=new Random();
while(true){
int num= random.nextInt();
try {
blockingQueue.put(num);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
producer.start();
customer.start();
}
}
阻塞队列的实现,自己写了这么多,我们也来看下jvm中是怎么实现阻塞队列的
我们可以看到确实是通过加锁的方式,只不过用的是ReentrantLock,在下一篇博客中,我们就会讲到ReentrantLock的使用
3.线程池
跟我们的常量池类似,线程池的出现就是为了减少线程频繁的创建/销毁所消耗的资源
标准库中的线程池
• 使⽤Executors.newFixedThreadPool(10)能创建出固定包含10个线程的线程池.
• 返回值类型为ExecutorService
• 通过ExecutorService.submit可以注册⼀个任务到线程池中.
ThreadPoolExecutor中有多个参数,现在我们就来说一下分别都是什么含义
1.corePoolSize:表示的是核心线程数,简单理解也就是线程池中最少要有多少个线程。
2.maximumPoolSize:表示最大线程数,也就是线程池中最多要有多少个线程
3.keepAliveTime:表示最长空闲时间,也就是允许非核心线程但是在线程池中的线程,最多可以空闲多久。
4.unit:空闲时间的单位
5.workQueue:一个阻塞队列
6.threadFactory:创建线程的工厂(又涉及到工厂模式)
7.handler:拒绝策略
这里我们详细来讲一下第七个参数拒绝策略
分为四种:
AbortPolicy():超过负荷,直接抛出异常.
CallerRunsPolicy():调⽤者负责处理多出来的任务.
DiscardOldestPolicy():丢弃队列中最⽼的任务.
DiscardPolicy():丢弃新来的任务.
现在我们来简单实现一下线程池
public class text {
private BlockingQueue<Runnable> queue=new LinkedBlockingQueue<>();
public void sub(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
public text(int n){
for (int i = 0; i <n; i++) {
Thread t1=new Thread(()->{
try {
Runnable runnable=queue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
}
}
}
(七)总结
解决线程安全的思路和方法;
1,使用没有共享资源的模型
2.不对共享变量进行写操作
3.通过加锁和一些原子操作来保证线程的顺序,可见性,和原子性