Java多线程(多线程基本操作,多线程安全问题等)

Posted caiyec

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java多线程(多线程基本操作,多线程安全问题等)相关的知识,希望对你有一定的参考价值。


一、创建线程四种方式

1)继承Thread

利用多态机制,继承于Thread机制
1)创建一个子类,继承于Thread
2)重写run 方法
3)创建子类实例
4)调用start 方法

public class Demo {
    //创建多线程
    public static void main(String[] args) {
        MyThread2 myThread2=new MyThread2();
        myThread2.start();
    }
}
class MyThread2 extends Thread{
    @Override
    public void run() {
       //这里写线程要执行的代码

    }
}

2)调用Runnable

通过实现Runnable 接口,把Runnable 接口的实例赋值给Thread
1)定义Runnable接口的实现类
2)创建Runnable实现类的实例,并用这个实例作为Thread的target来创建Thread对象,这个Thread对象才是真正的线程对象
3)调用start () 方法

public class Demo {
    //创建多线程
    public static void main(String[] args) {
       //通过Runnable 接口来创建
        Runnable myTask=new MyTask();
        Thread t=new Thread();
        t.start();

    }
}
class MyTask implements Runnable{
    @Override
    public void run() {
        //重写run()方法
    }
}

Runnable 本质上还是要搭配Thread来使用,只不过和直接继承Thread相比,换了一种指定任务的方式而已
这两种方式中Runnable 方式更好一点,能够让线程本身,和线程要执行的任务,更加“解耦合”

3)匿名内部类

通过匿名内部类相当于继承了Thread,作为子类重写run()实现

public class Demo {
    //创建多线程
    public static void main(String[] args) {
       //通过匿名内部类来实现
    Thread t=new Thread(){
        @Override
        public void run() {
        //重写run方法
        }
    };
    t.start();
    }
}

通过Runnable 匿名内部类来实现

public class Demo {
    //创建多线程
    public static void main(String[] args) {
       //通过匿名内部类来实现
   Thread t=new Thread(new Runnable() {
       @Override
       public void run() {
        //重写run方法
       }
    });
   t.start();
    }
}


4)使用lambda表达式来创建

public class Demo {
    //创建多线程
    public static void main(String[] args) {
    Thread t=new Thread(()->{
        //编写线程代码
    }
    );
    t.start();
    }
}

()->{ }这个就是lambda表达式

二、了解Thread 类

2.1Thread的常见的构造方法

Thread()创建线程对象
Thread(Runnable target)使用Runnable对象创建线程对象
Thread(String name)创建线程对象并命名
Thread(Runnable target,String name)使用Runnable对象来创建线程,并命名

2.2Thread的几个常见的属性

ID.getId()
名称.getName()
优先级.getPriority()
状态.getState()
是否后台线程.isDaemon()
是否存活.isAlive()
是否被中断.isInterrupted
获取当前线程的实例currentThread()

优先级和线程调度有关,由操作系统来完成。
后台线程,不影响整个进程的结束
前台线程,会影响到整个进程的结束
是否存活就是run()方法是否运行结束了

三、启动一个线程

start
start 是Thread类的一个关键方法
功能:让操作系统内核真正创建一个线程来执行

start 和run 的区别
start 是创建线程(有新的执行流)
调用run只是一个普通的方法调用,不涉及创建新线程(仍然在原来的线程中,没有涉及到新的执行流)
调用strat 方法

public class Demo {
    //start 和 run 的区别
    public static void main(String[] args) {
        MyThread2 myThread2=new MyThread2();
        myThread2.start();
        while(true){
            System.out.println("hehe");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}
class MyThread2 extends Thread{
    @Override
    public void run() {
        while(true){
            System.out.println("haha");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}


可以看到两个线程并发执行,
而如果是调用run()方法,就是普通的调用,没有创建新线程,一直在循环里出不来

四、中断一个线程

4.1 让线程的入口方法执行完毕

线程执行完毕,运行5S 线程执行完毕

public class Demo {
    //中断一个线程
    static boolean isRunning=true;
    public static void main(String[] args) {
        Thread t1=new Thread(){
            @Override
            public void run() {
                while(isRunning){
                    System.out.println("hello");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t1.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isRunning=false;
        System.out.println("线程运行5S结束");
    }
}

4.2 使用Thread类提供的interrupt方法

针对上面的方式进行修改
1,把上面的while()中判断条件进行修改
2,把catch里面的代码,加一个break
调用 interrupt()是通知线程结束,具体还是看内部代码的实现

public class Demo {
   //使用Thread 方法来中断线程
   public static void main(String[] args) {
       Thread t1=new Thread(){
           @Override
           public void run() {
               while(!Thread.currentThread().isInterrupted()){
                   System.out.println("hello");
                   try {
                       Thread.sleep(1000);
                   } catch (InterruptedException e) {
                      // e.printStackTrace();
                       break;
                   }
               }
           }
       };
       t1.start();

       try {
           Thread.sleep(5000);
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
       System.out.println("线程结束");
       t1.interrupt();
   }
}


五、等待线程

我们在创建多个线程之后,每个线程都是一个独立的执行流~
这些线程每个线程的执行顺序都是不确定的,完全取决于操作系统的调度,这里的等待线程机制就是一种确定线程先后顺序方式,确定线程的结束顺序,无法确定谁先开始,可以确定谁先结束 使用join()
join 起到的效果就是等待某个线程结束,谁调用join就等待谁结束
通过代码来解释:

public class Demo {
   //使用Thread 方法来中断线程
   public static void main(String[] args) {
       Thread t1=new Thread(){
           @Override
           public void run() {
            for(int i=0;i<5;i++){
                System.out.println("hello");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

           }
       };
       t1.start();
       try {
           t1.join();
       } catch (InterruptedException e) {
           e.printStackTrace();
       }

   }
}

这里在main 方法中调用了join,相当于在主线程中,等待t1线程结束
main方法在执行的时候,遇到join就会堵塞等待,一直等t1线程执行完毕,这个时候join才会继续往下执行
也就是说谁调用join 谁先结束

六、线程休眠

当执行sleep时就是让线程休眠,所谓的休眠就是把线程的task struct放入等待队列
CPU在执行的时候是挑等待队列中的线程来执行的,而sleep的线程在等待队列不在就绪队列,所以不会被执行
也可以在sleep中加上时间,等时间过去后,等待队列的线程才有机会到就绪队列,至于什么时候到就绪队列还是要看调度器执行

等待队列可能有好多了,具体谁先出队列,先回到就绪队列和设定的时间相关,如果时间一样就看系统的调度

七、线程的状态

在我们调试多线程程序有帮助

NEWThread 对象刚创建,还没有在系统中创建线程,相当于任务交给了线程,但是线程还没有开始执行
Runnable线程是一个准备就绪的状态,随时可能调度到CPU上执行,或者正在CPU上执行(线程的task struct在就绪队列中)
Blocked线程堵塞(线程在等待队列里)没有竞争到锁
Waiting线程堵塞(线程在等待队列里)调用waiting 方法
Timed_Waiting线程堵塞(线程在等待队列里)调用sleep方法
Terminated线程结束了(Thread对象还没销毁)

八、线程安全(重要)!!!

多线程虽然是更轻量的并发编程(相比于进程),但是线程是访问同一份内存资源,由于线程是一个抢占式执行的过程中谁先执行,谁后执行,不确定,完全却决于系统的调度。由于这里不确定性太多就可能导致多个线程访问同一个资源的时候,出现BUG所以引出了线程安全问题。 访问分为读和写操作,读操作不会涉及到线程安全问题,只有写操作涉及线程安全工作

多线程修改同一变量

public class Demo {
   //线程安全问题
    static class Counter{
        //创建一个自增类,通过线程调度来展示线程安全问题
        public int count=0;

        public void increase(){
            count++;
        }
   }

    public static void main(String[] args) {
        //通过两个线程同时对count进行自增
        Counter counter=new Counter();
        Thread t1=new Thread(){
            @Override
            public void run() {
                for(int i=0;i<50000;i++){
                    counter.increase();
                }
            }
        };
        Thread t2=new Thread(){
            @Override
            public void run() {
                for(int i=0;i<50000;i++){
                    counter.increase();
                }
            }
        };

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}

此处代码就是t1,t2 两个线程修改同一变量,存在线程安全问题,正常情况下count值应该是100000,但是运行下来,count值是在50000~100000之间,每次都在变化,这是为什么呢? 这里就是触发了线程安全的问题

这里我们先看count ++ 具体做了什么事情,这里我们就要引入JMM了
JMM(JVM 实现方式的抽象,Java 程序和内存之间是如何交互的)
1)先把内存数据读取到CPU的寄存器中
2)针对寄存器中的内容,通过类似于ADD这样的指令进行+1,操作的结果仍然是放在寄存器中里
3)把寄存器中的数据,写回到内存中

由于多线程之间是抢占式执行的,可能第一个线程执行自增一半时,就可能被调度出CPU,由第二个线程再次自增

LOAD就是从内存中读取数据到寄存器 ADD就是进行自增效果 SAVE就是将寄存器中数据写回内存中
如果出现第一个线程读取到数据为0时,在进行自增操作时,线程二也进行读取数据,两个线程都读到的是0,相当于两次自增操作只增加了一次,只要是线程二的读取不是在线程一SAVE操作后,就会发生自增异常的情况!所以两个线程分别自增50000次,数据最后的数值是在50000~100000之间的。这就是抢占式执行同一个资源所带来的异常!
如果这里是两个CPU也是同样的情况,这样的不确定性,不符合预期的要求,就认为是BUG,因为我们执行代码就是追求的是确定性

8.1导致线程不安全的原因:

由于多线程是抢占式执行,可能会出现第一个线程自增执行到一半就被调度出去,就会执行第二个线程。

1)线程的抢占式执行过程(无法修改)操作系统内核实现的
2)多个线程修改同一变量
3)修改操作不是原子性 (原子性:不可拆分)如果三个操作(读取,自增,写回)打包成一个整体,这就能解决了线程不安全问题,保证操作原子性,是保证线程安全的主要手段
4)内存可见性
两个线程同时操作一个内存,比如一个读一个写,写操作的线程进行修改时,读线程读取到的可能是修改之前的结果,也可能是读取到修改之后的结果,也会带来线程安全问题
内存可见性也可能是编译器优化,假设执行一个循环自增,这样的操作就涉及到大量的读写操作,读写内存的操作比访问CPU寄存器要慢几千倍,所以JVM往往对指令进行优化,把它等价转换成另外一种情况 保证逻辑不变的情况下,读取一次内存,之后进行自增,自增结束后在写回内存,节省了很多的 读写内存的开销,但是这样会触发线程不安全。

解决可见性,方案就是直接禁止这样的编译器的优化,让程序跑慢点,关键是要对,不能在多线程情况下出错

5)指令重排序
和线程不安全直接相关,也是和编译器优化直接相关,为了让程序跑的更快,调整了执行顺序~(调整的前提逻辑不改变,但是效率提高),如果是多线程的情况下可能重排会改变逻辑,会导致线程不安全问题。

8.2解决线程安全问题方法

1)多线程不修改同一变量

synchronized

synchronized(关键字) 监视器锁 ,我们通过加锁来保证操作原子性,同时禁止指令重排序和保证内存可见性

用法:
1.修饰一个方法 (方法前加上,就是针对代码进行加锁工作,调用方法就加锁,出了代码块就解锁)

2.修饰一个代码块(包裹起来) 针对哪个对象加锁,括号内就填哪个对象

分析synchroized 工作过程
使用synchronized 就是 相当于增加于给操作增加了两个指令 LOCK UNLOCK
LOCK 操作的特性,只有一个线程能执行成功,直到另一个线程释放UNLOCK 另一个线程才能执行
就比如上面演示的自增操作,加锁就相当于把LOAD ADD SAVE 三个操作打包为一个操作,这样就解决了线程的原子性,并且synchronized也能禁止编译器的进行内存可见性和指令重排序,所以使用synchronized就解决了线程安全问题,但是synchronized也付出了代价,程序运行的效率大大降低了。

注意synchroized 括号内填什么
针对哪个对象加锁,就填哪个对象,每一个对象能都加锁,如果多个线程竞争同一锁对象(尝试对同一个对象加锁,此时就会出现一个竞争成功,其他等待的情况),如果两个线程竞争不同的锁,两个线程都能成功获取到锁。

如果synchronized修饰的就是方法,相当于加锁的对象是this
如果synchronized修饰静态方法,相当于加锁的对象是类对象

错误示范:1)加锁对象错误

此时由于我们加锁的不是同一个对象,第一个为t1,第二个是t2,这两个加锁操作就不会构成竞争,没有作用

2)嵌套加锁 :

这里我们在给increase方法加了锁,还对counter加了一次锁,相当于连续加了两次锁,这会产生什么情况?
会产生死锁,因为
1)执行程序时,运行到循环时,因为对counter 对象就行了加锁,此时用LOCK将counter 对象锁起来
2)当程序运行到调用increase () 方法时,这个方法也有加锁,但是此时无法加锁,因为有锁在上面,所以increase ()方法就进入堵塞等待,等待上一个加锁操作进行释放~
3)但是上一个加锁释放是要执行完increase ()方法的,但是此时方法无法执行,这里就产生了死锁情况!

但是如果运行程序的话,可以运行成功,因为synchroized 内部对这种状况进行了解决,利用特殊的手段来处理这个场景“可重入锁
synchroized 如何实现的可重入锁效果?
synchroized内部记录了当前这把锁时哪个线程持有的, 如果当前加锁线程和持有线程是同一线程,而不是真的进行加锁,而是把一个计数器++ ,如果后续该线程继续尝试获取锁,继续判定加锁线程和持有线程是不是同一线程,只要是同一线程,就不真正加锁,而是计数器++,如果该线程调用解锁操作,也不是立即解锁,而是计数器- - ,直到计数器减为0了,才认为真的要“释放锁了 ”,才允许其他线程来获取锁~

volatile

起到的效果也是辅助保证线程安全~~ 主要是用于读写同一个变量的时候

volatile能够禁止指令重排序,和内存可见性,但是不能保证原子性

我们来设计一个场景 线程一进行循环,线程二通过修改循环条件,来使得线程1循环结束

import java.util.Scanner;

public class Demo {
  static class Counter{
      public int flag=0;
  }

    public static void main(String[] args) {
        Counter counter=new Counter();
            Thread t1=new Thread(){
                @Override
                public void run() {
                    while(counter.flag==0){
                        //do nothing
                    }
                    System.out.println("线程一循环结束");
                }
            };
            t1.start();
        Thread t2=new Thread(){
            @Override
            public void run() {
                Scanner scanner=new Scanner(System.in);
                System.out.println("请输入一个整数");
                counter.flag=scanner.nextInt();
            }
        };
        t2.start();
    }
}

我们预想的效果是输入一个非0整数之后,循环停止,但是运行程序发现,循环并没有停止,这是因为内存可见性,如果没有优化,此时CPU就需要频繁的读取内存数据,但是这时进行了优化,编译器就把这个读操作优化成只从内存中读一次,后续都直接读寄存器中数值,即使内存中数值发生变化,也不会读取到,
这种场景情况下,使用synchroized也可以,但是没必要因为volatile 比synchroized更轻量化,解决方法就是在flag 前加上volatile,加上volatile之后线程一每次读取flag的值都必须从内存中读取了(效率降低了,但是代码逻辑准确了)
但是volatile只使用于一读一写的情况,如果多个线程都要执行写操作,那么volatile就没有作用了,就要使用synchroized了

九、对象等待集

功能: 协调多个线程之间执行的先后顺序

对象等待集的应用场景

由于多线程之间是一个”抢占式执行“,可能会导致某个线程一直占用,其他线程就会出现线程饿死的情况,等待集就是解决线程太频繁占用,

实现等待集: wait () ,notify() ,notifyAll ()
wait /notify 这一系列方法必须搭配,synchronized 来使用,如果不在synchronized 使用就会出现异常,因为当前预期是获取到锁的状态才能调用wait(),没有synchronized相当于还没获取到锁,就尝试调用,于是就会出现异常

wait 内部做了三件事
1.释放锁
2. 等待其他线程的通知
3.等待通知之后,重新尝试获取锁
notify()

通知某个线程被唤醒,从wait中醒来,notify也是在synchroized中使用,调用notify()方法之后,代码不会立即释放锁,而是在执行完当前的synchroized之后才释放锁,同时等待中的线程就尝试重新竞争这个锁

演示wait 和 notify 用法

//演示wait 和 notify 用法
public class Demo {
    //创建一个锁对象
    static public Object Locker=new Object();
   //用来等待的线程
    static class WaitTask implements Runnable{以上是关于Java多线程(多线程基本操作,多线程安全问题等)的主要内容,如果未能解决你的问题,请参考以下文章

Java多线程-Java多线程概述

java多线程机制2(安全问题)

高并发多线程安全之信号量线程组守护线程线程栅栏等的分析

java的多线程:线程安全问题

Java多线程Thread线程安全

java多线程访问同一个数组,存在并发问题吗,每个线程访问的是数组的不同部分,不存在冲突