java线程学习

一、线程概述

1.1进程相关概念

1.进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是操作系统进行资源分配与调度的基本单位.

可以把进程简单的理解为正在操作系统中运行的一个程序.

2.线程

线程(thread)是进程的一个执行单元.

一个线程就是进程中一个单一顺序的控制流, 进程的一个执行分支

进程是线程的容器,一个进程至少有一个线程.一个进程中也可以 有多个线程.

在操作系统中是以进程为单位分配资源,如虚拟存储空间,文件描 述符等. 每个线程都有各自的线程栈,自己的寄存器环境,自己的线程 本地存储.

3.主线程与子线程

JVM 启动时会创建一个主线程,该主线程负责执行 main 方法 .线程就是运行 main 方法的线程

Java 中的线程不孤立的,线程之间存在一些联系. 如果在 A 线程中 创建了 B 线程, 称 B 线程为 A 线程的子线程, 相应的 A 线程就是 B 线 程的父线程

4.串行、并行、转发

并发可以提高以事物的处理效率, 即一段时间内可以处理或者完 成更多的事情.

并行是一种更为严格,理想的并发

1.2进程创建、开启

方法一:继承Thread并重写run方法

ThreadTest类

public class ThreadTest extends Thread{
    //方法一:继承Thread并重写run方法

    @Override
    public void run() {
        //run方法中的代码为子线程执行的任务
        System.out.println("子线程。。");
    }
}

MainTest类(main)

public class MainTest {
    public static void main(String[] args) {
        System.out.println("main方法线程");
        //创建子线程
        ThreadTest threadTest = new ThreadTest();
        /*
        调用start方法:
            此方法为请求JVM运行
            具体运行事件由线程调度器决定
         */
        threadTest.start();
        //输出测试
        System.out.println("main后");
    }
}

Start方法:

​ 此方法为请求JVM运行
​ 具体运行事件由线程调度器决定

多线程的执行结果与代码顺序无关

方法二:实现Runnable接口,并重写抽象方法

public class ThreadTest implements Runnable{
    /*
        方法二:
            实现Runnable接口,并重写抽象方法
     */
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            System.out.println("thread-->"+i);
        }
    }
}
public class Main {
    public static void main(String[] args) {
        //创建线程将接口的实现类传入参数
        ThreadTest t = new ThreadTest();
        Thread thread = new Thread(t);
        thread.start();
        for (int i = 0; i < 1000; i++) {
            System.out.println("main-》》》"+i);
        }

    }
}

1.3常用方法

1.currentThread()方法

创建的线程

public class ThreadTest implements Runnable{

    @Override
    public void run() {
        //打印当前线程
        System.out.println("run方法的当前线程--》"+Thread.currentThread().getName());
    }
}

主方法

public class Main {
    public static void main(String[] args) {
        //创建线程将接口的实现类传入参数
        ThreadTest t = new ThreadTest();
        Thread thread = new Thread(t);
        thread.start();
        System.out.println("main方法的当前进程--》"+ Thread.currentThread().getName());

    }
}

currentThread()获取的就是调用这一段代码的线程

2.set/getName()

thread.setName(线程名称), 设置线程名称 thread.getName()返回线程名称

通过设置线程名称,有助于程序调试,提高程序的可读性, 建议为 每个线程都设置一个能够体现线程功能的名称

3.isAlive()

thread.isAlive()判断当前线程是否处于活动状态

活动状态就是线程已启动并且尚未终止

4.sleep()

Thread.sleep(millis); 让当前线程休眠指定的毫秒数

当前线程是指 Thread.currentThread()返回的线程

5.getId()

thread.getId()可以获得线程的唯一标识

注意: 某个编号的线程运行结束后,该编号可能被后续创建的线程使用

重启的 JVM 后,同一个线程的编号可能不一样

6.yieId()

Thread.yield()方法的作用是放弃当前的 CPU 资源,

7.setPriority()

thread.setPriority( num ); 设置线程的优先级 java 线程的优先级取值范围是 1 ~ 10 ,

在操作系统中,优先级较高的线程获得 CPU 的资源越多 线程优先级本质上是只是给线程调度器一个提示信息,以便于调度器决定先调度哪些线程. 注意不能保证优先级高的线程先运行.

线程的优先级具有继承性, 在 A 线程中创建了 B 线程,则 B 线程的 优先级与 A 线程是一样的.

8.interrupt()

中断线程.

注意调用 interrupt()方法仅仅是在当前线程打一个停止标志,并不是真正的停止线程

二、线程安全问题

非线程安全主要是指多个线程对同一个对象的实例变量进行操作 时,会出现值被更改,值不同步的情况.

线程安全问题表现为三个方面: 原子性,可见性和有序性

1. 原子性

原子(Atomic)就是不可分割的意思. 原子操作的不可分割有两层含义:

  1. 访问(读,写)某个共享变量的操作从其他线程来看,该操作要么已经执行完毕,要么尚未发生, 即其他线程年示到当前操作的中 间结果

  2. 访问同一组共享变量的原子操作是不能够交错的

Java 有两种方式实现原子性: 一种是使用; 另一种利用处理器 的 CAS(Compare and Swap)指令(硬件层次)

2. 可见性

在多线程环境中, 一个线程对某个共享变量进行更新之后 , 后续其他的线程可能无法立即读到这个更新的结果

3. 有序性

有序性(Ordering)是指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器运行的其他线程看来是乱序的(Out of Order).

3.1重排序

编译器可能会改变两个操作的先后顺序; 处理器也可能不会按照目标代码的顺序执行;

这种一个处理器上执行的多个操作,在其他处理器来看它的顺序与目标代码指定的顺序可能不一样

与内存操作顺序有关的几个概念:

  • 源代码顺序:.java文件中指定的顺序。

  • 程序顺序:.class文件中指定的顺序。

  • 执行顺序:处理器实际执行顺序。

  • 感知顺序,给定处理器所感知到的该处理器及其他处理器的内存访问操作的顺序

3.2 指令重排序

源码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,

我们就说发生了指令重排序(Instruction Reorder).

javac 编译器一般不会执行指令重排序, 而 JIT 编译器可能执行指 令重排序.

指令重排不会对单线程程序的结果正确性产生影响,可能导致多 线程程序出现非预期的结果

3.3 存储子系统重排序

写缓冲器高速缓存(Cache)

高速缓存是CPU 中为了匹配与主内存处理速度不匹配而设计的一个高速缓存

写缓冲器(Store buffer, Write buffer)用来提高写高速缓存操作的效率

存储子系统重排序并没有真正的对指令执行顺序进行调整,而是造成一种指令执行顺序被调整的现象

LoadLoad 重排序

StoreStore重排序

LoadStore 重排序

3.4 貌似串行语义

进行重排序, 给单线程程序造成一种假象----指令是按照源码的顺序执行的.这种假象称为貌似串行语义.

并不能保证多线程环境 程序的正确性

为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序

2.3.5 保证内存访问的顺序性

可以使用 volatile 关键字, synchronized 关键字实现有序性

4.java内存模型

每个线程都有独立的栈空间,并且都可以访问堆内存

计算机的cpu读数据顺序:把主存数据读到cache,再把cache中的数据读到寄存器中,所以cpu从寄存器中读数据

产生可见性问题的原因

  1. ​ jvm共享的数据可能分配到寄存器中,其他cpu不能读到该cpu的寄存器,所以导致可见性问题
  2. ​ jvm共享的数据可能分配到主存中,线程对数据的更新可能只更新到cpu的写缓冲器其他cpu无法看到该cpu的写缓冲器中的数据

解决:

缓存同步:cpu通过缓存一致性协议可以读取其他cpu的缓存中的数据,并将数据更新到本缓存中

​ 缓存同步可以解决不同cpu对数据的更新后的读取,即为了保障了可见性,必须保证数据的更新最终写到cpu的cache中,此操作称为冲刷处理器缓存

三、线程同步

1. 线程同步机制

线程同步机制是一套用于协调线程之间的数据访问的机制.该机制可以保障线程安全.

Java 平台提供的线程同步机制包括:

锁, volatile 关键字, final 关键 字,static 关键字,以及相关的 API,如 Object.wait()/Object.notify()等

2.锁

  • 锁思路:将多个线程对共享数据并发访问转换为串行访问
  • 临界区(Critical Section): 锁的持有线程在获得锁之后和释放锁之前这段时间所执行的代码称为.
  • 性质:锁具有排他性(Exclusive), 即一个锁一次只能被一个线程持有.这 种锁称为排它锁或互斥锁(Mutex).

2.1锁的作用

作用:锁可以实现对共享数据的安全访问

原理:锁是通过互斥保障原子性. 一个锁只能被一个线程持有, 这就保 证临界区的代码一次只能被一个线程执行.

可见性的保障,在 java 平台中,锁的获得隐含着刷新处理器缓存的动作, 锁的释放隐含着冲刷处理器缓存的动作

​ 锁能够保障有序性.写线程在临界区所执行的在读线程所执行的临界区看来像是完全按照源码顺序执行的.

注意:使用锁保障线程的安全性,必须满足以下条件:

  • 这些线程在访问共享数据时必须使用同一个锁
  • 即使是取共享数据的线程也需要使用同步

2.2锁的相关概念

1.可重入性

一个线程持有该锁的时候能否再次(多次)申请该锁

如果一个线程持有一个锁的时候还能够继续成功申请该锁,称该 锁是可重入的, 否则就称该锁为不可重入的

2.锁的争用与调度

3.锁的粒度

一个锁可以保护的共享数据的数量大小.

锁保护共享数据量大,称该锁的粒度粗,

  • 锁的粒度过粗会导致线程在申请锁时会进行不必要的等待.
  • 过细会增加锁调度的开销

2.3synchronized

Java 中的每个对象都有一个与之关联的内部锁(Intrinsic lock).

这种锁也称为监视器(Monitor), 这种内部锁是一种排他锁,可以保障线程安全.

在方法中加入synchronized(this){},则可以使用内部锁实现同步代码块必须使用同一锁

public class Main1 {
    public static void main(String[] args) {
        Main1 main1 = new Main1();
        new Thread(new Runnable() {
            @Override
            public void run() {
                main1.test01();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                main1.test01();
            }
        }).start();

    }
    public void test01(){
        synchronized (this){
            for (int i = 0; i < 100; i++) {
                System.out.println(Thread.currentThread().getName() +"==>"+ i);
            }
        }
    }
}

1 同步实例方法

默认使用this作为锁对象

public synchronized void test01(){
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() +"==>"+ i);
        }
    
}

2 同步静态方法/类锁

默认使用运行时类作为锁对象

public synchronized static void test01(){
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() +"==>"+ i);
        }
    
}

3 同步代码块和同步方法的区别:

代码块:锁的粒度细

方法: 锁的粒度粗(因为方法内有其他等待时间,此时也加入到了锁中,因此其他等待时间也是串行进行的)

4 脏读

读取到旧值

解决:读和写值得时候使用同一把锁

5 异常

线程出现异常时,会自动释放锁

6 死锁

解决:所有线程获取锁的顺序相同,则不会出现问题

2.4 volaile

轻量级同步机制

作用: volatile 关键的作用使变量在多个线程之间可见.

2.5 volatile和synchronized比较

  1. volatile性能肯定比 synchronized 要好;
  2. volatile 只能修饰变量,而 synchronized 可以修饰方法,代码块.
  3. 多线程访问 volatile 变量不会发生阻塞,而 synchronized 可能会 阻塞
  4. volatile 能保证数据的可见性,但是不能保证原子性; 而 synchronized 可以保证原子性,也可以保证可见性
  5. 关键字 volatile 解决的是变量在多个线程之间的可见性 synchronized 关键字解决多个线程之间访问公共资源的同步性

2.6 原子类进行自增自减操作(基于CAS)

private static AtomicInteger count = new AtomicInteger();

count.getAndIncrement();

3. CAS

是JDK提供的非阻塞原子性操作,通过硬件保证比较-更新操作的原子性。

CAS原理:在把数据更新到主内存时,再次读取主内存变量的值,如 果现在变量的值与期望的值(操作起始时读取的值)一样就更新.

i++自增原理:

  1. 从主内存读取 i 变量值
  2. 对 i 的值加 1
  3. 再把加 1 之后 的值保存到主内存

3.1 CAS的ABA问题

ABA问题: 是否认为 count 值不变的情况下没有被其他线程更新呢? 这种结果是否能够接受?

​ 这就是 CAS 中的 ABA 问题,即共享变量经历了 A->B->A 的更新

规避: 如果想要规避 ABA 问题,可以为共享变量引入一个修订号(时间 戳), 每次修改共享变量时,相应的修订号就会增加 1

​ AtomicStampedReference 类就是基于这种思想产生的

3.2 原子变量类

原子变量类内部就是借助一个 Volatile 变量, 并且保障了该变量的 read-modify-write 操作的原子性,

有时把原子变量类看作增强的 volatile 变量. 原子变量类有 12

  • 基本类型
    • AtomicBoolean - 布尔类型原子类
    • AtomicInteger - 整型原子类
    • AtomicLong - 长整型原子类
  • 引用类型
    • AtomicReference - 引用类型原子类
    • AtomicMarkableReference - 带有标记位的引用类型原子类
    • AtomicStampedReference - 带有版本号的引用类型原子类
  • 数组类型
    • AtomicIntegerArray - 整形数组原子类
    • AtomicLongArray - 长整型数组原子类
    • AtomicReferenceArray - 引用类型数组原子类
  • 属性更新器类型
    • AtomicIntegerFieldUpdater - 整型字段的原子更新器。
    • AtomicLongFieldUpdater - 长整型字段的原子更新器。
    • AtomicReferenceFieldUpdater - 原子更新引用类型里的字段。

1 AtomicIntegerFieldUpdater

可以对原子整数字段进行更新,要求:

  1. 字符必须使用 volatile 修饰,使线程之间可见

  2. 只能是实例变量,不能是静态变量,也不能使用 final 修饰

public class User { int id; volatile int age;
private AtomicIntegerFieldUpdater<User> updater = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");
System.out.println( updater.getAndIncrement(user));

四、线程间的通信

4.1等待/通知机制

1.什么是等待通知机制

比如生产者和消费者模型,消费者等待生产者生产资源,这是等待,生产者生产好资源通知等待的消费者去消费,这是通知。

2.wait()/notify()

Object 类中的 wait()方法可以使执行当前代码的线程等待,暂停执 行,直到接到通知或被中断为止.

注意:

  1. wait()方法只能在同步代码块中由锁对象调用
  2. 调用 wait()方法,当前线程会释放锁
public class Main3 {
    public static void main(String[] args) throws InterruptedException {
        String str = "string";
        synchronized (str){
            //在同步代码块中进行wait
            System.out.println("同步代码块开始");
            str.wait();
            System.out.println("wait后。。");
        }
        System.out.println("同步代码块后。。");
    }
}

Object 类的 notify()可唤醒线程,该方法也必须在同步代码块中由锁对象调用 .

注意:

  1. 如果有多个等待的线程,notify()方法 只能唤醒其中的一个.
  2. 调用 notify()方法后,并不会立即释放锁对象,需要等当前同步代码块执行完后才会释放锁对象,一般将 notify()方法放在同步代码块的最后
public class Main3 {
    public static void main(String[] args) throws InterruptedException {
        String str = "string";
        //创建线程t1使str等待
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //wait在同步代码块中由锁对象调用
                synchronized (str){
                    try {
                        System.out.println("str即将等待");
                        //str等待
                        str.wait();
                        System.out.println("str等待结束");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        //创建线程t2唤醒str
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (str){
                    System.out.println("对str进行唤醒");
                    //唤醒str
                    str.notify();
                    System.out.println("唤醒成功");
                }
            }
        });
        t1.start();
        //主线程睡眠两秒,确保t1执行
        Thread.sleep(2000);
        t2.start();
    }
}

3.notifyAll()

notify()一次只能唤醒一个线程,如果有多个等待的线程,只能随机唤醒其中的某一个;

想要唤醒所有等待线程,需要调用 notifyAll().

4. wait(long)的使用

wait(long)带有 long 类型参数的 wait()等待,如果在参数指定的时间 内没有被唤醒,超时后会自动唤醒

public class Test07 {
	public static void main(String[] args) {
		final Object obj = new Object();
		Thread t = new Thread(new Runnable() {
			@Override
			public void run() {
				synchronized ( obj ){
					try {
						System.out.println("thread begin wait");
						obj.wait(5000); //如果 5000 毫秒内没有被唤醒 ,会自动唤醒
						System.out.println("end wait....");
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			}
		});
		t.start();
	}
}

5.通知过早

如果 notify()唤醒的 过早,在等待之前就调用了notify()可能会打乱程序正常的运行逻辑.

6.interrupt()方法会中断 wait()

当线程处于 wait()等待状态时, 调用线程对象的 interrupt()方法会 中断线程的等待状态, 会产生 InterruptedException 异常

7.wait 等待条件发生了变化

在使用 wait/nofity 模式时,注意 wait 条件发生了变化,也可能会造 成逻辑的混乱

public class Main3 {
    public static void main(String[] args) throws InterruptedException {
        Thread threadSubtarct = new ThreadSubtract();
        Thread threadSubtarct2 = new ThreadSubtract();
        Thread threadAdd = new ThreadAdd();

        threadSubtarct.setName("subtract1线程");
        threadSubtarct2.setName("subtract2线程");
        threadSubtarct.start();
        threadSubtarct2.start();
        threadAdd.start();


    }
    /**
     *  1.创建list集合
     */
    static List list = new ArrayList<>();
    /**
     * 2.定义方法从集合取数据
     */
    public static void subtract(){
        synchronized (list){
            //当集合为空则等待
            //注:不可以使用if
            while(list.size() == 0){
                System.out.println("集合内无元素"+ Thread.currentThread().getName()+"begin wait.");
                try {
                    list.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+"end wait.");
            }
            //不为空取元素
            Object data = list.remove(0);
            System.out.println("集合内有元素"+ Thread.currentThread().getName()+"取出了"+data+"元素");
        }
    }
    /**
     * 3.定义方法集合添加数据
     */
    public static void add(){
        synchronized (list){
            list.add("data");
            System.out.println( Thread.currentThread().getName() + "存储了一个数据");
            //通知所有线程存储完毕
            list.notifyAll();
        }
    }
    /**
     * 4.定义线程类调用subtract方法
     */
    static class ThreadSubtract extends Thread{
        @Override
        public void run() {
            subtract();
        }
    }
    /**
     * 5.定义线程类调用add方法
     */
    static class ThreadAdd extends Thread{
        @Override
        public void run() {
            add();
        }
    }
}

8.生产者消费者模式

多生产多消费情况下:

1. 判断等待的条件应使用while
2. 唤醒线程时,应使用notifyAll().否则可能出现假死状态(所有线程均处于等待)

4.2通过管道实现线程间的通信

public class Main1 {
    public static void main(String[] args) throws IOException {
        PipedOutputStream pipedOutputStream = new PipedOutputStream();
        PipedInputStream pipedInputStream = new PipedInputStream();
        pipedInputStream.connect(pipedOutputStream);
        new Thread(new Runnable() {
            @Override
            public void run() {
                writeData(pipedOutputStream);

            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                readData(pipedInputStream);
            }
        }).start();


    }
    /**
     * 1,从管道流中读取数据
     */
    public static void readData(PipedInputStream inputStream){
        byte[] bytes = new byte[1024];
        //从管道读取的数据保存到数组中
        //len为读到的字符数,无数据时读到-1
        try {
            int len = inputStream.read(bytes);
            while(len != -1){
                //把bytes数组中从0到len个字节的字节数据转换为String类型进行打印
                System.out.println(new String(bytes,0,len));
                //继续读取
                len =  inputStream.read(bytes);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if (inputStream != null){
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

        }
    }
    /**
     * 2.从管道流中写入数据
     */
    public static void writeData(PipedOutputStream outputStream){
        //把1-100写到管道中
        try {
            for (int i = 1; i <= 100; i++) {
                String data = String.valueOf(i);
                outputStream.write(data.getBytes());
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if (outputStream != null){
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

        }
    }
}

4.3 ThreadLocal的使用

除了控制资源的访问外, 还可以通过增加资源来保证线程安全. ThreadLocal 主要解决为每个线程绑定自己的值

重写初始值

第一次调用 get()方法不会返回 null

/**
 * 重写ThreadLocal的初始值
 */
static class SubThreadLoacl extends ThreadLocal<Date>{
    @Override
    protected Date initialValue() {
        return new Date();
    }
}

五、Lock显示锁

在JDK5中增加了lock锁接口

有 ReentrantLock实现类,ReentrantLock 锁称为可重入锁, 它功能比 synchronized 多.

5.1 锁的可重入性

锁的可重入是指,当一个线程获得一个对象锁后,再次请求该对象锁时是可以获得该对象的锁

5.2 ReentrantLock

1. ReentrantLock 的基本使用

调用 lock()方法获得锁, 调用 unlock()释放锁

用同一把锁才可以实现同步

    /**
     *定义显示锁
     */
    static Lock lock = new ReentrantLock();
    public static void test01(){
        try {
            //上锁
        	lock.lock();
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName()+"-->"+i);
            }
        } catch (Exception e) {
            e.printStackTrace();

        }finally {
            //解锁放在finally中
            lock.unlock();
        }
       
    }
    public static void main(String[] args) {
        //创建线程
        new Thread(new Runnable() {
            @Override
            public void run() {
                test01();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                test01();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                test01();
            }
        }).start();

    }

}

2.lockInterruptibly()方法

lock.lockInterruptibly();

lockInterruptibly() 方法的作用:如果当前线程未被中断则获得锁, 如果当前线程被中断则出现异常

区别:

  • 对于 synchronized 内部锁来说,如果一个线程在等待锁,只有两个结果:要么该线程获得锁继续执行;要么就保持等待.
  • 对于 ReentrantLock 可重入锁来说,提供另外一种可能,在等待锁的过程中,程序可以根据需要取消对锁的请求.

作用:

​ 解决死锁:如果等待一段时间后线程状态为alive,中断某一个线程

3.tryLock()方法

tryLock(long time, TimeUnit unit) 的作用:限时等待.

给定等待时长内锁没有被另外的线程持有,并且当前线程也没有被中断,则获得该锁.

public class ReentrantLockTest {
    static class TimeLock implements Runnable{
        //获得显示锁
        private static ReentrantLock lock = new ReentrantLock();

        @Override
        public void run() {
            try {
                //如果线程在三秒内没有获得锁对象则放弃
                //lock.tryLock()返回值boolean类型
                if(lock.tryLock(3, TimeUnit.SECONDS)){
                    System.out.println(Thread.currentThread().getName()+"获得锁。。");
                    //执行2秒的任务
                    Thread.sleep(2000);
                }
                else {
                    System.out.println(Thread.currentThread().getName()+"没有获的锁");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if(lock.isHeldByCurrentThread()){
                    lock.unlock();
                }
            }
        }
    }

    public static void main(String[] args) {
        Runnable runnable = new TimeLock();
        Thread thread = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread.start();
        thread2.start();

    }

}

tryLock()

如果该锁没有被其他其他线程占有,则获得

如果其他线程占有该锁,则不等待,返回false

4.newCondition()方法

可以实现通知等待机制

注意:

  • await()/signal()方法,需要线程持有相关的Lock 锁.
  • 调用 await()后线程会释放这个锁,
  • 需要获得锁后才可以进行唤醒操作
public class ReentrantLockTest {
    static Lock lock = new ReentrantLock();
    /**
    获得condition对象
     */
    static Condition condition = lock.newCondition();
    static class TimeLock implements Runnable{
        @Override
        public void run() {
            try {

                lock.lock();
                System.out.println("method lock..");
                //线程等待
                System.out.println("method await");
                condition.await();

            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
                System.out.println("method unlock");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(new TimeLock()).start();
        Thread.sleep(3000);
        //主线程睡眠3秒后唤醒线程
        try {
            //需要获得锁后才可以进行唤醒操作
            lock.lock();
            System.out.println("method signal");
            condition.signal();

        } finally {
            lock.unlock();
        }

    }

}

5.Condition实现两个线程交替打印

生产者消费者模式

public class ReentrantLockTest {
    //获取锁对象
    static Lock lock = new ReentrantLock();
    //获取condition对象
    static Condition condition = lock.newCondition();
    //设置打印标识
    static boolean ifPrintNum = false;
    static class Service{
        //1.打印数字
        public void printNum(){
            //判断是否可以打印
            while (!ifPrintNum){
                try {
                    lock.lock();
                    //方法等待
                    condition.await();
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    //释放锁
                    lock.unlock();
                }
            }
            //可以打印
            System.out.println("888888");
            //设置标志为false
            ifPrintNum = false;
            //通知其他线程可以打印了
            try {
                lock.lock();
                condition.signal();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }


        }
        //2.打印字母
        public void printWord(){
            //判断是否可以打印
            while (ifPrintNum){
                try {
                    lock.lock();
                    //方法等待
                    condition.await();
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    //释放锁
                    lock.unlock();
                }
            }
            //可以打印
            System.out.println("ZZZZZZZ");
            //设置标志为false
            ifPrintNum = true;
            //通知其他线程可以打印了
            try {
                lock.lock();
                condition.signal();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        Service service = new Service();
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 20; i++) {
                    service.printNum();
                }

            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 20; i++) {
                    service.printWord();
                }

            }
        }).start();
    }

6.公平锁与非公平锁

  • 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
  • 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

注意:

ReentrantLock(boolean fair)

公平锁看起来很公平,但是要实现公平锁必须要求系统维护一个有序队列,公平锁的实现成本较高,性能也低.

因此默认情况下锁是非公平的. 不是特别的需求,一般不使用公平锁.

7.常用方法

方法 描述
int getHoldCount() 返回当前线程调用 lock()方法的次数
int getQueueLength() 返回正等待获得锁的线程预估数
int getWaitQueueLength(Condition condition) 返回与 Condition 条件相关的等待的线程预估数
boolean hasQueuedThread(Thread thread) 查询参数指定的线程是否 在等待获得锁
boolean hasQueuedThreads() 查询是否还有线程在等待获得该锁
boolean hasWaiters(Condition condition) 查询是否有线程正在等待 指定的 Condition 条件
boolean isFair() 判断是否为公平锁
boolean isHeldByCurrentThread() 判断当前线程是否持有该锁
boolean isLocked(); 查询当前锁是否被线程持有

5.3 ReentrantReadWriteLock

  1. 排他锁:
    synchronized 内部锁与 ReentrantLock 锁都是独占锁(排它锁), 同一 时间只允许一个线程执行同步代码块,可以保证线程的安全性,但 是执 行效率低.

  2. 改进的排他锁:

    允许多个线程同时读取共享数据,但是一次只允许一个 线程对共享数据进行更新.

  3. ReentrantReadWriteLock:读读共享,读写互斥,写写互斥


//获得读写锁
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//申请获取写锁
readWriteLock.writeLock().lock();
//释放写锁
readWriteLock.writeLock().unlock();

六、线程管理

6.1 线程组

类似于在计算机中使用文件夹管理文件,也可以使用线程组来管理线程. 在线程组中定义一组相似(相关)的线程,在线程组中也可以定义子线程组

  • Thread 类有几个构造方法允许在创建线程时指定线程组,如果在 创建线程时没有指定线程组则该线程就属于父线程所在的线程组.
  • JVM创建main线程时会为它指定一个线程组,因此每个 Java 线程都 有一个线程组与之关联

在 main 线程中创建了 t1 线程:

​ 称main 线程为父线程, t1线程为子线程, t1没有指定线程组则t1线程就归属到父线程 main 线程的线程组中

创建线程时,可以指定线程所属线程组:

//group1为线程组名称,r为Runable接口名,t2为线程名称
Thread t2 = new Thread(group1, r, "t2");

1.常用方法

activeCount() 返回当前线程组及子线程组中活动线程的数量(近似值)
activeGroupCount() 返回当前线程组及子线程组中活动线程组的数量 (近似值)
int enumerate(Thread[] list) 将当前线程组中的活动线程复制到参数数组中
enumerate(ThreadGroup[] list) 将当前线程组中的活动线程组复制到参数数组中
getMaxPriority() 返回线程组的最大优先级,默认是 10
getName() 返回线程组的名称
getParent() 返回父线程组
interrupt() 中断线程组中所有的线程
isDaemon() 判断当前线程组是否为守护线程组
list() 将当前线程组中的活动线程打印出来
parentOf(ThreadGroup g) 判断当前线程组是否为参数线程组的父线 程组
setDaemon(boolean daemon) 设置线程组为守护线程组

2.守护线程

守护线程是为其他线程提供服务的,当 JVM 中只有守护线程时,守 护线程会自动销毁,JVM 会退出.

6.2捕获线程的运行异常

如果线程产生了异常, JVM 会调用 dispatchUncaughtException()方法,在该方法中调用了 getUncaughtExceptionHandler().uncaughtException(this, e);

  • 如果当前线程设置了 UncaughtExceptionHandler 回调接口就直接调用它自己的 uncaughtException 方法,
  • 如果没有 设置则调用当前线程所在线程组 UncaughtExceptionHandler 回调接口的 uncaughtException 方 法,
  • 如果线程组也没有设置回调接口,则直接把异常的栈信息定向到 System.err 中
public class ThreadException {
    public static void main(String[] args) {
        /**
         * 设置全局的线程异常回调接口
         */
        Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                System.out.println(t.getName()+"线程产生了:"+e.getMessage());
            }
        });
        new Thread(new Runnable() {
            @Override
            public void run() {
                //会发生算数异常
                System.out.println(12/0);
            }
        },"t1").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                //会发生空指针异常
                String str = null;
                System.out.println(str.length());
            }
        },"t2").start();

    }
}

6.3注入Hook钩子线程

通常情况下,我们可以向应用程序注入一个或多个 Hook (钩子) 线程,这样,在程序即将退出的时候,也就是 JVM 程序即将退出的时候,Hook 线程就会被启动执行

1 应用场景

上面我们已经知道了, Hook 线程能够在 JVM 程序退出的时候被启动且执行,那么,我们能够通过这种特性,做点什么呢?

罗列一些常见应用场景:

  1. 防止程序重复执行,具体实现可以在程序启动时,校验是否已经生成 lock 文件,如果已经生成,则退出程序,如果未生成,则生成 lock 文件,程序正常执行,最后再注入 Hook 线程,这样在 JVM 退出的时候,线程中再将 lock 文件删除掉;

  2. Hook 线程中也可以执行一些资源释放的操作,比如关闭数据库连接,Socket 连接等。

2 注意事项

  1. Hook 线程只有在正确接收到退出信号时,才能被正确执行,如果你是通过 kill -9这种方式,强制杀死的进程,那么抱歉,进程是不会去执行 Hook 线程的,为什么呢?你想啊,它自己都被强制干掉了,哪里还管的上别人呢?
  2. 请不要在 Hook 线程中执行一些耗时的操作,这样会导致程序长时间不能退出。

6.4线程池

1.什么是线程池

线程池就是有效使用线程的一种常用方式.

线程池内部可以预先创建一定数量的工作线程,客户端代码直接将任务作为一个对象提交给线程池, 线程池将这些任务缓存在工作队列中, 线程池中的工作线程不断地从队列中取出任务并执行.

2.JDK对线程池API

3.基本使用

public class ThreadException {
    public static void main(String[] args) {
        //线程池的基本使用(大小为5)
        ExecutorService threadPool = Executors.newFixedThreadPool(5);

        //向线程池提交18个任务
        for (int i = 0; i < 18; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName()+"正在执行任务");
                    try {
                        //执行两秒
                        TimeUnit.SECONDS.sleep(2);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    }
}

scheduledExecutorService.scheduleAtFixedRate

scheduledExecutorService.scheduleWithFixedDelay

4.底层实现

1.线程池分类

线程池名称 特点 使用范围 类别
newSingleThreadExecutor() 如果这个唯一的线程因为异常终止,则有一个新的线程来替代它。保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行 单一生产者消费者模式时 单线程的线程池
newFixedThreadPool(int 如果设定的所有线程都在运行,新任务会在任务队列等待 典型且优秀 固定大小的线程池
newCachedThreadPool() 大小可伸缩的线程池。如果当前没有可用线程,则创建一个线程。在执行结束后缓存60s,如果不被调用则移除线程。 大量耗时短且提交频繁的任务 可缓存的线程池

2.创建流程

3.ThreadPoolExecutor

上述线程池类型都是对ThreadPoolExecutor线程池的封装

public ThreadPoolExecutor(int corePoolSize,
                           int maximumPoolSize,
                           long keepAliveTime,
                           TimeUnit unit,
                           BlockingQueue<Runnable> workQueue,
                           ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {……}

重要的几个参数详解

corePoolSize:核心线程数,也是线程池中常驻的线程数,线程池初始化时默认是没有线程的,当任务来临时才开始创建线程去执行任务

maximumPoolSize:最大线程数,在核心线程数的基础上可能会额外增加一些非核心线程,需要注意的是只有当workQueue队列填满时才会创建多于corePoolSize的线程(线程池总线程数不超过maxPoolSize)
keepAliveTime:非核心线程的空闲时间超过keepAliveTime就会被自动终止回收掉,注意当corePoolSize=maxPoolSize时,
keepAliveTime参数也就不起作用了(因为不存在非核心线程);

unit:keepAliveTime的时间单位

workQueue:用于保存任务的队列,可以为无界、有界、同步移交三种队列类型之一,当池子里的工作线程数大于corePoolSize时,这时新进来的任务会被放到队列中

threadFactory:创建线程的工厂类,默认使用Executors.defaultThreadFactory(),也可以使用guava库的ThreadFactoryBuilder来创建

handler:线程池无法继续接收任务(队列已满且线程数达到maximunPoolSize)时的饱和策略,取值有AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy

5.好处

1、降低资源消耗。重复利用已创建线程,降低线程创建与销毁的资源消耗。

2、提高响应效率。任务到达时,不需等待创建线程就能立即执行。

3、提高线程可管理性。

4、防止服务器过载。内存溢出、CPU耗尽。

6.拒绝策略

即线程池中的线程已经用完了,等待队列也满了,无法为新提交的任务服务,可以通过拒绝策略来处理这个问题. JDK 提供了四种拒绝策略:

  • AbortPolicy 策略, 会抛出异常 (默认拒绝策略)
  • CallerRunsPolicy 策略, 只要线程池没关闭,会在调用者线程中运行 当前被丢弃的任务
  • DiscardOldestPolicy策略 将任务队列中最老的任务丢弃,尝试再次提交 新任务
  • DiscardPolicy策略 直接丢弃这个无法处理的任务

如果内置的拒绝策略无法满足实际需求,可以扩展 RejectedExecutionHandler 接口

7.ThreadFactory线程工厂

线程池中的线程从哪儿来的? 答案就是 ThreadFactory.

ThreadFactory 是一个接口,只有一个用来创建线程的方法: Thread newThread(Runnable r);

当线程池中需要创建线程时就会调用该方法

8.监控线程池的API

方法 描述
ThreadPoolExecutor 提供了一组方法用于监控线程池
int getActiveCount() 获得线程池中当前活动线程的数量
long getCompletedTaskCount() 返回线程池完成任务的数量
int getCorePoolSize() 线程池中核心线程的数量
int getLargestPoolSize() 返回线程池曾经达到的线程的最大数
int getMaximumPoolSize() 返回线程池的最大容量
int getPoolSize() 当前线程池的大小
BlockingQueue getQueue() 返回阻塞队列
long getTaskCount() 返回线程池收到的任务总数

8.扩展线程池

实现线程池的内部类来实现拓展

public class ThreadException {
    private static class MyTask implements Runnable{
        public String name;

        public MyTask(String s) {
            this.name = s;
        }

        public void setName(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }

        @Override
        public void run() {
            System.out.println(name+ "正在被"+Thread.currentThread().getName()+"执行");
            //执行任务1秒钟
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }


        }
    }
    public static void main(String[] args) {
        //定义扩展线程池
        ExecutorService executorService =  new ThreadPoolExecutor(5,5,0,TimeUnit.SECONDS,new LinkedBlockingDeque<>()){
            /**
             * 执行线程前执行的方法
             * @param t
             * @param r
             */
            @Override
            protected void beforeExecute(Thread t, Runnable r) {
                System.out.println(t.getName()+"准备执行任务:"+((MyTask)r).getName());
            }

            /**
             * 执行线程后执行
             * @param r
             * @param t
             */

            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                System.out.println(((MyTask)r).getName()+"执行完毕");
            }

            /**
             * 线程池退出后执行
             */
            @Override
            protected void terminated() {
                System.out.println("线程池退出");
            }
        };
        //向线程池中添加任务
        for (int i = 0; i < 5; i++) {
            MyTask myTask = new MyTask("task"+i);
            //执行
            executorService.execute(myTask);
        }
        //关闭线程池(后续任务不会加入,以后任务结束后退出)
        executorService.shutdown();

    }
}

9.优化线程池大小

线程池大小对系统性能是有一定影响的,过大或者过小都会无法发挥最优的系统性能, 线程池大小不需要非常精确,只要避免极大或者极小的情况即可,

一般来说,线程池大小需要考虑 CPU 数量,内存大小等因素.

在书中给出一个估算线程池 大小的公式: 线程池大小 = CPU 的数量 * 目标 CPU 的使用率*( 1 + 等待时间 与计算时间的比)

10.线程池的死锁

适合给线程池提交相互独立的任务,而不是彼此依赖的任务. 对于彼此依赖的任务,可以考虑分别提交给不同的线程池来执行.

11.线程池异常

在使用 ThreadPoolExecutor 进行 submit 提交任务时,有的任务抛出了异常,但是线程池并没有进行提示,即线程池把任务中的异常给吃掉 了,

解决:

​ 可以把 submit 提交改为 execute 执行,也可以对 ThreadPoolExecutor 线程池进行扩展.对提交的任务进行包装:

12.ForkJoinPool线程池

“分而治之”是一个有效的处理大数据的方法,著名的 MapReduce 就是采用这种分而治之的思路.

把一个大任务调用 fork()方法分解为若干小的任务,把小任务的处 理结果进行 join()合并为大任务的结果

大数求和:

public class ThreadException {
    /**
     * 计算大数求和的任务方法
     */
    private static class CountTask extends RecursiveTask<Long>{

        //定义数据阈值,超过则采用分解
        private static final int THRESHOLD = 10000;
        //定义分解的任务数
        private static final int TASKNUM = 100;
        //每次任务的起始位置
        private long start;
        //每次任务的结束位置
        private long end;

        public CountTask(long start,long end) {
            this.start = start;
            this.end = end;
        }

        //重写 RecursiveTask 类的 compute()方法,计算数列的结果
        @Override
        protected Long compute() {
            //作为计算后的总结果
            long sum = 0;
            //判断是否到达阈值
            if(end-start < THRESHOLD){
                //直接计算
                for (long i = start; i <=end; i++) {
                    sum += i;
                }
            }
            else {
                //分解
                //把任务分解为约定份数,计算每个任务的求和值
                long step = (end-start)/TASKNUM;
                //1.创建存储任务的集合
                ArrayList<CountTask> tasks = new ArrayList<>();
                //计算每个任务的起始位置
                long taskStart = start;
                //2.计算每个任务的求和值
                for (int i = 0; i <= TASKNUM; i++){
                    //计算结束值
                    long taskEnd = taskStart+step;
                    if (taskEnd > end){
                        taskEnd = end;
                    }
                    //创建子任务
                    CountTask task = new CountTask(taskStart,taskEnd);
                    //将子任务添加到集合中
                    tasks.add(task);
                    //调用fork提交子任务
                    task.fork();
                    taskStart = taskEnd+1;
                }
                for (CountTask task : tasks) {
                    //join()会一直等待子任务执行完毕返回执行结果
                    sum+=task.join();
                }
            }
            return sum;
        }
    }

    public static void main(String[] args) {

        //创建线程池
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        //创建任务
        CountTask countTask = new CountTask(0L,200000L);
        try {
            //将任务提交到线程池
            long startTime = System.currentTimeMillis();
            ForkJoinTask<Long> result = forkJoinPool.submit(countTask);
            Long sum = result.get();
            System.out.println("计算结果为:"+sum);
            long endTime = System.currentTimeMillis();
            System.out.println("计算时间:"+(endTime-startTime));
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        long startTime2 = System.currentTimeMillis();
        long sum2 = 0;
        for (long i = 0; i <= 200000L; i++) {
            sum2+=i;
        }
        long endTime2 = System.currentTimeMillis();
        System.out.println(sum2);
        System.out.println(endTime2-startTime2);

    }
}

七、保证线程安全的设计技术

不借助锁来保证线程安全

7.1java运行时存储空间:

堆空间和非堆空间是线程可以共享的空间,即实例变量与静态变量是线程可以共享的,可能存在线程安全问题.

栈空间是线程私有的 存储空间,局部变量存储在栈空间中,局部变量具有固定的线程安全性

7.2无状态对象

对象的状态:

​ 实例变量与静态变量称为状态变量.

无状态对象:

​ 实际上无状态对象就是不包含任何实例变量也不包含任何静态变量对象.

线程安全问题:

​ 前提是多个线程存在共享数据,要避免这个问题:使用无状态对象

7.3不可变对象

不可变对象是指一经创建它的状态就保持不变的对象,不可变对象具有固有的线程安全性.

当不可变对象现实实体的状态发生变化时,系统会创建一个新不可变对象(就如 String 字符串对象.)

需要满足以下条件:

  1. 类本身使用 final 修饰,防止通过创建子类来改变它的定义

  2. 所有的字段都是 final 修饰的,final 字段在创建对象时必须显示 初始化,不能被修改

  3. 如果字段引用了其他状态可变的对象(集合,数组),则这些字段 必须是 private 私有的

应用场景:

  1. 被建模对象的状态变化不频繁

  2. 同时对一组相关数据进行写操作,可以应用不可变对象,既可以 保障原子性也可以避免锁的使用

  3. 使用不可变对象作为安全可靠的Map键,

7.4线程特有对象

对于非线程安全的对象, 每个线程都创建一个该对象的实例,各个线程线程访问各自创建的实例,一个线程不能访问另外一个线程创建的实例

此对象称为线程特有对象

ThreadLocal :

  • 即各个线程通过 ThreadLocal 对象可以创建并访问各自的线程特有对象,泛型 T 指 定了线程特有对象的类型.
  • 一个线程可以使用不同的 ThreadLocal 实 例来创建并访问不同的线程特有对象

7.5装饰器模式

非线程安全的对象创建一个相应的线程安全外包装对象

客户端代码不直接访问非线程安全的对象而是访问它的外包装对象

在 java.util.Collections 工具类中提供了一组 synchronizedXXX(xxx) 可以把不是线程安全的 xxx 集合转换为线程安全的集合,它就是采用 了这种装饰器模式. 这个方法返回值就是指定集合的外包装对象.这 类集合又称为同步集合.

好处:关注点分离

​ 对于非线程安全的在设计时只关注要实现的功能,对于线程安 全的版本只关注线程安全性

八、锁的优化及注意事项

8.1 锁的优化

1.减少锁持有时间

如synchronized只锁需要的部分

2.减小锁的粒度

减少锁粒度 是一种削弱多线程锁竞争的一种手段,可以提高系统的并发性

在JDK7前,java.util.concurrent.ConcurrentHashMap类采用分段锁协议,可以提高程序的并发性

3.使用读写分离锁代替独占锁

使用ReadWriteLock读写分离锁可以提高系统性能, 使用读写分离锁也是减小锁粒度的一种特殊情况.

在多数情况下都允许多个线程同时读,在写的使用采用独占锁,在 读多写少的情况下,使用读写锁可以大大提高系统的并发能力.

4.锁分离

如 java.util.concurrent.LinkedBlockingQueue 类中 take()与 put()方法分别从队头取数据,把数据添加到队尾.

take()操作的是链表 的头部,put()操作的是链表的尾部,两者并不冲突(take取数据时 有取锁, put添加数据时有自己的添加锁)

5.粗锁化

如果对同一个锁不断的进行请求,同步和 释放,也会消耗系统资源

把所有的锁整合成对锁的一次请求,从而减少对锁的请求次数,这个操作叫锁的粗化

8.2JVM对锁的优化

偏向锁,轻量级锁都是乐观锁,重量级锁是悲观锁

1.锁偏向

如果一个线程获得了锁,那么锁就进入偏向模式, 当这个线程再次请求锁时,无须再做任何同步操作

这样可以节省有关锁申请的时间,提高了程序的性能.

锁偏向在没有锁竞争的场合可以有较好的优化效果,对于锁竞争 比较激烈的场景,效果不佳

2.轻量级锁/重量级锁

转换情况:

1.对象实例时,是可偏向的,当第一个线程访问它时,对象就会偏向这个线程

​ 此时线程修改对象头中的ThreadId 改成自己的 ID(再使用对象时,只需比对即可)

2.当有第二个线程访问该对象时,由于偏向锁不会主动释放,因此需要查看该对象的偏向状态

​ 此时检查原来的线程是否存活,不存活则对象变为无锁状态,存活则马上执行原来线程的栈

3.如果执行仍需要偏向锁,则升级为轻量级锁,第二个线程开始自旋

4.此时如果第二线程自旋超过了一定次数,或第三个线程访问,轻量级锁膨胀为重量级锁

​ (重量级锁除了持有锁的线程外,其他的线程都阻塞.)

posted @ 2021-08-26 19:59  橡皮筋儿  阅读(108)  评论(1编辑  收藏  举报