多线程
进程的调度 操作系统实际调度的基本单位是线程
操作系统内核里为了管理所有的进程(线程),有一个链表。链表中存储了当前进程的pid以及当前线程的tid
系统内核中有一个专门的等待队列和就绪队列
当执行sleep时,就是让这个线程进如等待队列中,而等待完成后就进入到就绪队列中。
线程只有在就绪队列里,才可能被执行。
等待队列中可能会有多个线程,所以等待队列并不是一个先进先出的队列,而是一个优先级队列,谁的等待结束时间最早,谁就先从等待队列中进入到就绪队列
线程的状态
Thread.State是一个枚举类型,描述了当前线程在干什么
NEW:线程对象刚创建出来,但是还没有调用start
RUNNABLE:线程是就绪的状态,随时都可能调度到CPU上执行。或者说正在执行中
BLOCKED:表示当前线程阻塞,线程在获取对象的同步锁时,该锁被其它线程占用,就把该线程放入锁池中
WAITING:表示当前线程阻塞,可能此线程调用了wait方法
TIMED WAITING:表示当前线程阻塞,可能线程调用了sleep方法
TERMINATED:线程结束了,但是线程对象还没有销毁
线程安全
多线程虽然比多进程更轻量的完成了并发编程,但是多个线程是访问同一份内存资源的,由于线程是一个抢占式的执行过程(哪个线程先执行,哪个线程后执行,这个完全取决于系统的调度器)由于不确定性太多,就可能导致多个线程访问同一个资源的时候,出现bug(多个线程同时要CPU执行自己)这就是线程安全问题。
访问一个资源分为读和写:
如果多个线程只是进行读,没有线程安全问题
如果多个线程涉及到写,才会有线程安全问题
如果多个线程有读有写,也会有线程安全问题
如下代码就为线程不安全,多个线程访问同一个资源:
public class Demo08 {
static int i = 0;
public static void main(String[] args) {
Thread t1 = new Thread(){
@Override
public void run() {
for (int j = 0; j < 10000 ; j++) {
i++;
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
for (int j = 0; j < 10000 ; j++) {
i++;
}
}
};
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(i);
}
}
从代码可以看出,预期输出结果为20000,但实际结果不足20000,这就是线程不安全,发生了线程抢占的情况。
i++操作就针对内存中的i进行修改操作,修改操作都是由CPU来完成的,分为以下几步:
先把内存中的数据读取到CPU的 寄存器中(寄存器比内存小,读取速度比内存快)LOAD
针对寄存器中的内容,通过ADD这样的指令来对其进行+1操作,操作结果仍存放在寄存器中 ADD
把寄存器中的内容存储到内存中 SAVE
在发生多个线程对同一个资源进行访问时,就会出现某个线程对其修改的内容还没有存储到内存上时,就已经被其它线程继续读取了,所以就会出现上述情况
在上述代码中,如果每次LOAD都在SAVE之后,那么结果就与预期一样,但如果每次LOAD都在SAVE之前,那么结果就为10000,因为每次都是不确定的,所以实际结果应为10000-20000
而写代码追求的是确定性,这样的方式存在很大的不确定性,所以就认为是BUG
导致线程不安全的原因:
线程的抢占式执行过程(操作系统内核要求的,改变不了)
多个线程,访问同一个资源(需求要求,改变不了)
修改操作不是原子的 (原子:不可拆分)
内存可见性
指令重排序
保证操作的原子性,是保证线程安全问题的主要手段
如果LOAD ADD SAVE 打包成一个整体,禁止穿插执行,那也就可以保证线程安全了。
内存可见性
因为JVM在执行过程中,为了效率是在内存上工作的,这样一来就无法确保别的线程获取数据时是从内存上获取的最新数据了,这就是内存可见性。
本质就是编译器的优化导致了线程1修改的数据有没有及时的写入到内存中,线程2就读取到的数据是否是最新数据。编译器会在整体逻辑不变的情况下对指令进行调整,做出一些更优化的执行方案,从而提高程序的效率。
如,I连续自增1w次,可能只会进行一次LOAD与SAVE,其它的LOAD与SAVE就被优化了,所以当其它线程读取数据时,只会时原来的i。
这样做虽然可以节省很多读写内存的开销,但是容易影响其它线程。
解决可见性问题:禁止这样的编译器优化,程序跑的慢一点是可以接受的,但是不能影响程序的可确定性
指令重排序
与线程不安全直接相关,也与内存可见性直接相关
为了让程序跑的更快,调整了程序的顺序,他的前提也是保证程序的逻辑不发生变化,从而提高效率
synchronized 锁
如果要保证线程的安全,就要从 原子性 内存可见性 指令重排序 来入手
这就要通过synchronized(监视器锁),功能就是保证操作的原子性,同时禁止指令重排序和保证内存可见性,他的用法就是修饰一个方法或者修饰一个代码块(编译器看到synchronized修饰的方法,就会放弃指令重排序和内存可见性)
加锁之后,只要进入synchronized修饰的代码块/方法后,就会自动加锁,代码块/方法运行结束后,就会自动解锁。如:
public class Demo08 {
static int i = 0;
synchronized public static void increase(){
i++;
}
public static void main(String[] args) {
Thread t1 = new Thread(){
@Override
public void run() {
for (int j = 0; j < 10000 ; j++) {
increase();
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
for (int j = 0; j < 10000 ; j++) {
increase();
}
}
};
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(i);
}
}
这样一来就相当于在LOAD之前添加一个LOCK的操作,在SAVE之后添加一个UNLOCK的操作。
LOCK操作的特性:只有一个线程可以LOCK成功,一旦某个线程进行LOCK操作,其它线程要进行LOCK就会阻塞,直到这个线程UNLOCK操作完成,才可以轮到其它线程LOCK。
所以在Java中,两个线程竞争同一个锁时,就会出现一个成功,一个等待的情况,而竞争不同锁时,就不会出现等待的情况。
针对代码块加锁时,一定要注意针对哪个对象进行加锁,如下:
例1:
Thread t1 = new Thread(){
@Override
public void run() {
for (int j = 0; j < 100000000 ; j++) {
synchronized (this){
increase();
}
}
}
};
例2:
Demo08 d = new Demo08();
Thread t1 = new Thread(){
@Override
public void run() {
for (int j = 0; j < 100000000 ; j++) {
synchronized (d){
d.i++;
}
}
}
};
例1中加锁对象为t1,此时就起不到对变量加锁的作用,这里就起不到线程安全的作用。(锁住访问变量的唯一方式,而不是锁住此代码块)
如果加锁的对象是方法,则分为两种情况
非静态方法,相当于加锁的对象,就是this
静态方法,相当于加锁的对象,就是类对象
volatile
volatile也是辅助保证线程安全的,可以禁止指令重排序,保证内存可见性,但不保证原子性。
主要用于读写同一个变量时,如:
public class Demo09 {
static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(){
@Override
public void run() {
while (count == 0){
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
Scanner sc = new Scanner(System.in);
System.out.print("请输入一个值:");
count = sc.nextInt();
}
};
t1.start();
t2.start();
}
}
此时虽然也可以使用 synchronized 进行锁操作,但是没必要。于是就可以使用volatile,volatile比synchronized更轻量,更高效,就可以解决这个问题
那么就可以给变量count添加volatile关键词,此时线程要读取volatile就需要从内存中读取了,虽然效率低,但是保证了代码的准确性。
public class Demo09 {
volatile static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(){
@Override
public void run() {
while (count == 0){
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
Scanner sc = new Scanner(System.in);
System.out.print("请输入一个值:");
count = sc.nextInt();
}
};
t1.start();
t2.start();
}
}
所以在这种一个线程写,其它线程读的情况下,就可以使用volatile关键词,就没必要使用synchronized关键词了。
但是如果两个线程都要进行写操作,那么使用volatile就不能保证线程安全了,还是需要使用synchronized
对象等待集
由于线程之间是一个抢占式执行的过程,所以会有一种情况:某个线程解锁后又抢占到CPU进行加锁,其它线程一直在等待状态下,于是就可以使用对象等待集。
具体API:wait notify notifyAll
主要的作用就是协调多个线程之间执行的先后顺序
join保证两个线程按照一定的先后顺序结束。
对象等待集必须在synchronized中使用,否则就会产生ILLegalMonitorSatate异常,Monitor指的就是监视器,也就是synchronized
用法:
public class Demo10 {
public static void main(String[] args) {
Object a = new Object();
synchronized (a){
try {
a.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
但是这这样的操作是没有意义的,线程进入wait后并不能自己再运行起来,所以就需要在多线程中使用
public class Demo10 {
static public Object o = new Object();
public static void main(String[] args) {
Object a = new Object();
Thread t1 = new Thread(){
@Override
public void run() {
synchronized (o){
try {
o.wait();
for (int i = 0; i <10 ; i++) {
System.out.println("线程1");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
synchronized (o) {
for (int i = 0; i < 10; i++) {
System.out.println("线程2");
}
o.notify();
}
}
};
t1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.start();
}
}
wait操作相当于
先释放锁
然后等待其它线程通知
再次尝试获取锁
而notify的操作就相当于通知某个线程的wait状态结束,也是需要在synchronized,而调用完notify之后不会立即释放锁,而是会先把本锁的代码执行完毕,在进行释放锁,让其他线程获取锁
notify是唤醒一个线程(具体是哪个线程是不确定的),而notifyAll是唤醒所有线程
调用wait、notify、加锁,都是要对同一个对象进行操作
单例模式
单例模式就是一种设计模式,在代码中有些对象只应该有一个实例,就称为单例模式。也可以认为是在语法上强制让某个类只有一个实例。
单例模式的出现,主要是依赖于关键字static,被其修饰的成员为静态成员。
单例模式又分为两种风格:懒汉模式、饿汉模式
一般认为懒汉模式更高效,
这两种模式中,懒汉模式存在线程安全问题。
饿汉模式
创建实例时在类加载阶段,比较早。
可以理解为打开一个页面时,加载完所有的资源再对用户进行展示。
例如:
class HungryMan {
private static HungryMan hungryMan = new HungryMan();
public static HungryMan getInstance(){
return hungryMan;
}
private HungryMan(){}
}
public class Hungry {
public static void main(String[] args) {
HungryMan h = HungryMan.getInstance();
}
}
懒汉模式
创建实例是在第一次调用获取对象的方法时,比较迟。
可以理解为打开一个页面时,先加载主页面的资源就开始对用户进行展示,后面的资源等用到的时候再加载。
例如:
class LazyMan{
private static LazyMan lazyMan = null;
public static LazyMan getInstance(){
if (lazyMan == null){
lazyMan = new LazyMan();
}
return lazyMan;
}
private LazyMan(){}
}
public class Lazy {
public static void main(String[] args) {
LazyMan l = LazyMan.getInstance();
}
}
这段代码中存在线程安全问题(判断lazyMan == null
时,如果两个线程都在这里执行,就会创建出两个对象,就失去了单例模式的本意)。
所以就可以通过加锁的方式来保证线程安全。
方法一:
synchronized public static LazyMan getInstance(){
if (lazyMan == null){
lazyMan = new LazyMan();
}
return lazyMan;
}
针对此方法加锁,那么就相当于针对 判断、new、返回 进行加锁,判断、new、返回三个操作都是串行的。
方法二:
public static LazyMan getInstance(){
synchronized (LazyMan.class){
if (lazyMan == null){
lazyMan = new LazyMan();
}
}
return lazyMan;
}
此时,针对 判断和new 进行加锁,所以 判断和new 是串行的,而返回并不是串行的
但是这样一来,每次调用这个方法时,进入判断操作,都会进入锁状态,这样是比较低效的,因为线程安全问题只出现在 lazyMan 为 null 时,其它时候,lazyMan 都是不需要进行初始化的,不涉及线程安全问题了,就不需要进行加锁了。
所以我们的需求是第一次调用此方法时加锁,后续并不需要加锁,那么就可以这样做:
public static LazyMan getInstance(){
if (lazyMan == null){
synchronized (LazyMan.class){
if (lazyMan == null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
这样一来,除了第一波线程会进入锁状态(包括获取到锁,以及获取不到进入阻塞状态的线程),后续都不会进入锁状态,直接返回方法的返回值。
细节(第一个线程获取到锁后进行 lazyMan 的初始化,然后释放锁,其它进入阻塞状态的线程先等待,然后获取锁后进行判断,再进行释放锁,等到第二波线程进入后,在锁外面进行判断后就直接返回方法返回值,不需要获取锁了)。
但是第一个if中读取数据的操作,有时并不会在内存中读取,还可能在寄存器中直接读取,所以就可能存在某个或多个线程在第一个判断时都为null,所以我们可以给 lazyMan 添加volatile关键字,保证多线程读取数据都是最新的数据。
volatile private static LazyMan lazyMan = null;
public static LazyMan getInstance(){
if (lazyMan == null){
synchronized (LazyMan.class){
if (lazyMan == null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
补充
如果对某个对象进行了两次加锁(如,对方法加锁的同时,也对调用这个方法的代码块加了锁)那么一旦进入这个代码块,就相当于加锁了,在调用这个方法,就会发现方法的对象处于LOCK状态,于是这个线程就会进入到阻塞等待状态,等待这个对象解锁。于是就进入了一个环路等待的情况,视这种情况为死锁
但是这种情况不会发生在 synchronized 上,synchronized会使用特殊的手段来处理这种情况,可以理解为”可重入锁“
如果要加锁的线程和持有锁(要加锁的对象的锁)的线程是同一 个线程,那么此时就不是真的加锁,而是把一个计数器进行自增,同样的,解锁操作也就是自减,如果计数器为0就释放锁,其它线程就可以获取锁了。
synchronized 的原理就是把多线程的 并行 改为一个线程的 串行 ,去除了抢占式带来的随机性
原子性
例如
i++;
i--;
i+=2;
i*=2;
// 这样的操作不是原子性的,这样的操作是先读取,再修改,最后再写入内存中。
if(i == 10) i = 0;
// 这样的操作也不是原子性的,先读取,再判断,再赋值(写回内存)
// 如果是直接赋值,有的是原子的,又的不是原子的
int i = 1 ;
i = 2 ;
// 这种操作是原子的,以及byte、short、char、boolean...的赋值,这些都是原子的
// 但是doule、long、引用类型...的赋值,这些就不是原子的
能不加锁,就不加锁,加锁频率尽量降到最低,
线程安全的单例模式,三个重点:
加锁 :在合适的位置加锁,保证把 判断和new 都包裹起来
双重if :保证需要加锁时,再加锁,不需要时,直接返回
volatile :保证外层 if 读操作,读到的值都是内存中最新的值