目录
前言:
单例模式
饿汉模式
代码实现
懒汉模式
代码实现
阻塞队列
生产者消费者模型
标准库中阻塞队列使用(实现生产者消费者模型)
模拟实现阻塞队列
代码实现
小结:
这篇主要介绍一些多线程的使用案例,使用多线程的编程方式解决一些实际问题。在多线程的代码编写里,一定要注意线程安全问题,及其一些其他内存可见性等问题。
所谓单例模式,就是通过语法的结构,使一个类只能实例出一个对象。不管在什么情况下,这个对象始终是同一份,即它们引用所指向的内存空间也是同一块空间。
所谓饿汉模式,就是在类加载的时候,就已经实例化了这个对象,我们只需要对外提供获得这个实例的方法即可。为了确保这个类只能实例出一个对象,就可以将它的构造方法设置为私有。类外不能够调用构造方法,即类外也就无法实例化对象。
public class Singleton {
//static修饰属于类属性,在类加载(解析class文件)时就创建好了,只有唯一一份,同时也就只有这一个对象
private static Singleton singleton = new Singleton();
//只能通过这个方法获取实例
public static Singleton getInstance() {
return singleton;
}
//将构造方法设为私有
private Singleton() {};
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2);
}
}
注意:多线程情况下,当我们调用getInstance获取这个类的实例时,只涉及到读取singleton这个实例的内存地址。那么天然就是线程安全的。
所谓懒汉模式,就是在调用获取类实例方法时在实例化对象,相比于饿汉模式就显得没有那么急切。还是一样将它的构造方法设置为私有。
public class SingletonLazy {
private volatile static SingletonLazy singletonLazy = null;
//在实际调用方法时实例化对象
public static SingletonLazy getInstance() {
if(singletonLazy == null) {
synchronized (SingletonLazy.class) {
if(singletonLazy == null) {
singletonLazy = new SingletonLazy();//指令重排序
}
}
}
return singletonLazy;
}
private SingletonLazy() {};
public static void main(String[] args) {
SingletonLazy s1 = SingletonLazy.getInstance();
SingletonLazy s2 = SingletonLazy.getInstance();
System.out.println(s1 == s2);
System.out.println("aaaa");
}
}
注意:
获取实例的SingletonLazy 方法里涉及写内存和读内存,那么在多线程环境下就会出现线程不安全情况。线程不安全情况就是因为可能两个线程读取到内存中的值是一样的,对于代码来说就有可能都读取singletonLazy为null,对于里面if而言,就会实例两次。
解决方法,在里面的if外加锁,保证if块里代码的原子性,那么当第一个线程读取的时候,另一个线程就会阻塞等待,直到上一个线程修改完成,这个线程才会取读取singletonLazy,这样就有效的避免了上述情况。
加锁对于cpu是有一定消耗的,会影响代码的执行效率。这里只有当两个线程同时读取singletonLazy为null时,才会产生线程安全问题。如果读取到已经不为null,那么就不需要加锁,直接返回这个对象的引用即可。代码里就是最外层if。
由于SingletonLazy 方法里涉及写内存和读内存,那么对于singletonLazy 变量就存在内存可见性问题。如果编译器认为这个变量是不可变的,当在代码里已经修改这个变量,但是编译器始终只读取cpu寄存器上的值,那么就会导致读取到的值和内存中的值不同步。volatile 关键字就是声明这个变量是可变的,编译器每次读取值的时候,先去内存中读取,这样就不会存在不同步的情况。
singletonLazy = new SingletonLazy();这句代码涉及指令重排序。这句代码涉及三个大步骤:1.申请内存。2.调用构造方法,实例这块内存。3.返回内存地址。如果在单线程环境下,不论怎样都是没有问题的。多线程环境下如果先执行了1,3就被调度走了,那么获得这个内存就是不完整的。volatile关键字可以禁止对其进行指令重排序。
特性:
1)如果队列为空,出队列就会阻塞,阻塞到队列不为空为止。
2)如果队列满了,入队列就会阻塞,阻塞到队列不满为止。
基于阻塞队列提出了消息队列,在每个数据中加上”消息类型“的标签,按照类型进行先进先出的模式。消息队列已经被一些大佬写成了程序,部署在一组服务器上,我们就可以通过客户端的方式发请求,使用消息队列。
由于阻塞队列的特点可以实现生产者消费者模型。
特性:
1)实现发送方和接收方之间的解耦和。
2)做到”削峰填谷“,保证系统的稳定性。
解释:
如果两个服务器之间直接进行调用,那么其中一个服务器挂了,就会影响其他的服务器,耦合性太高。在其之间加上阻塞队列(消息队列),发送方服务器先把数据入到阻塞队列中,然后接收方服务器直接从阻塞队列中取数据即可。这样就算接收方服务器挂了,但是阻塞队列还存在,就不会直接影响发送方服务器。这样就降低了服务器之间的耦合程度。并且服务器中的代码也只需要针对阻塞队列即可,方便后期的维护。
如果某一时刻,请求量大量增加。如果服务器和服务器是直接调用,就可能存在服务器崩掉的情况。那么在其之间加上阻塞队列,就算请求量大量增加,当阻塞队列满的时候就会阻塞,后面的服务器也是照常处理数据。阻塞队列就可以起到一个缓冲的作用。
public class ThreadDemo25 {
public static void main(String[] args) {
BlockingQueue blockingQueue = new LinkedBlockingQueue<>();
//生产者
Thread t1 = new Thread(() -> {
int count = 0;
while (true) {
System.out.println("生产:" + count);
try {
blockingQueue.put(count);
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
}
});
//消费者
Thread t2 = new Thread(() -> {
while (true) {
try {
Integer tmp = blockingQueue.take();
System.out.println("消费:" + tmp);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
}
}
注意:t1线程作为生产者不断入数据,t2线程作为消费者,不断取数据。BlockingQueue中只有put方法和take方法带有阻塞功能,入数据和出数据。
队列可以基于链表,数组,优先级队列实现。这里采用循环数组的方式实现。
循环数组,用head和tail记录有效数据的区间。所谓循环数组,就是当数据入到最后一个位置时就可以在前面空位置继续入数据,这里只需要对head和tail进行处理即可。
class MyBlockingQueue {
private int[] elem;
int head = 0;
int tail = 0;
int size = 0;
public MyBlockingQueue() {
this.elem = new int[1000];
}
public void put(int value) throws InterruptedException {
//队列满了,如果再如数据就阻塞,阻塞到队列不满为止
//虽然notify通知之后,队列就不满了,为了防止通知了队列任然是满的,这里用while循环
//这个方法及右读又有写,多线程环境下是不安全的,那么就需要保证这些代码的原子性
synchronized (this) {
while (elem.length == size) {
this.wait();
}
elem[tail] = value;
tail++;
//tail = tail % elem.length;
if(tail >= elem.length) {
tail = 0;
}
this.size++;
//唤醒take中的wait
this.notify();
}
}
public Integer tack() throws InterruptedException {
//如果队列空了,再出数据就阻塞,阻塞到队列不空为止
//虽然notify通知之后,队列就不为空了,为了防止通知了队列任然是空的,这里用while循环
int tmp = 0;
synchronized (this) {
while (this.size == 0) {
this.wait();
}
tmp = elem[head];
head++;
if(head >= elem.length) {
head = 0;
}
size--;
//唤醒put中的wait
this.notify();
}
return tmp;
}
}
public class ThreadDemo22 {
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue myBlockingQueue = new MyBlockingQueue();
myBlockingQueue.put(1);
myBlockingQueue.put(2);
myBlockingQueue.put(3);
System.out.println(myBlockingQueue.tack());
System.out.println(myBlockingQueue.tack());
System.out.println(myBlockingQueue.tack());
myBlockingQueue.tack();
}
}
注意:这里根据阻塞队列的特性,入数据满了就需要阻塞,这里调用wait方法,把锁加到this对象上,因为只有对同一个对象中多线程访问才会产生问题。当队列不满时,即出数据了,阻塞就停止了,进入就绪队列。这里在take方法里出完数据就调用notify方法通知put方法阻塞结束。
当阻塞结束时,队列真的就是不满的了么,这里可能会存在一些其他问题。为了代码足够稳妥,这里使用while循环,通知之后再进行判断。
这里的put方法里涉及了读和写数据,多线程情况下就会存在线程不安全情况,即对这块代码加锁,保证其原子性。其他线程访问时就阻塞等待。tack方法的原理和put方法一致。
写多线程代码时,要考虑线程的抢占式执行,随机调度带来的线程安全问题,及其一些其他多线程可能存在的问题。