Java多线程重游

1. 线程和进程

进程

  1. 一个在内存中运行的应用程序。

  2. 每个进程又有自己独立的一块内存空间,一个进程可以有多个线程

线程

  1. 进程中的一个任务(控制单元),负责进程中程序的执行。一个进程至少有一个线程,一个进程可以有多个线程,多个线程课共享数据。
  2. 同类的多个线程共享进程的堆和方法区的资源,每个进程都有自己的程序计数器,虚拟机栈,本地方法栈

总结

根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位

资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的

影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

2. 线程的创建方式

2.1 实现Runable接口

实现步骤

1、定义一个类实现Runnable接口;

2、创建该类的实例对象obj;

3、将obj作为构造器参数传入Thread类实例对象,这个对象才是真正的线程对象;

4、调用线程对象的start()方法启动该线程;

代码实例:

public class ImpRunnable implements Runnable {
	
	private int i;
	
	@Override
	public  void run() {
		for(;i < 50;i++) {	
			//当线程类实现Runnable接口时,要获取当前线程对象只有通过Thread.currentThread()获取
			System.out.println(Thread.currentThread().getName() + " " + i);
		}
	}
 
	public static void main(String[] args) {
		for(int j = 0;j < 30;j++) {
			System.out.println(Thread.currentThread().getName() + " " + j);
			if(j == 10) {
				ImpRunnable thread_target = new ImpRunnable();
				//通过new Thread(target,name)的方式创建线程
				new Thread(thread_target,"线程1").start();
				new Thread(thread_target,"线程2").start();
			}
			
		}
 
	}
 
}

代码相关:

1、实现Runnable接口的类的实例对象仅仅作为Thread对象的target,Runnable实现类里包含的run()方法仅仅作为线程执行体,而实际的线程对象依然是Thread实例,这里的Thread实例负责执行其target的run()方法;

2、通过实现Runnable接口来实现多线程时,要获取当前线程对象只能通过Thread.currentThread()方法,而不能通过this关键字获取;

3、从JAVA8开始,Runnable接口使用了@FunctionlInterface修饰,也就是说Runnable接口是函数式接口,可使用lambda表达式创建对象,使用lambda表达式就可以不像上述代码一样还要创建一个实现Runnable接口的类,然后再创建类的实例。

4、通过这种方式创建线程,可以使多线程共享线程类的实例变量,因为这里的多个线程都使用了同一个target实例变量。但是,当你使用我上述的代码运行的时候,你会发现,其实结果有些并不连续,这是因为多个线程访问同一资源时,如果资源没有加锁,那么会出现线程安全问题

2.2 继承Thread类

实现步骤

1、定义一个类继承Thread类,并重写Thread类的run()方法,run()方法的方法体就是线程要完成的任务,因此把run()称为线程的执行体;

2、创建该类的实例对象,即创建了线程对象;

3、调用线程对象的start()方法来启动线程;

代码实例:

public class ExtendThread extends Thread {
 
	private int i;
	
	public static void main(String[] args) {
		for(int j = 0;j < 50;j++) {
			
			//调用Thread类的currentThread()方法获取当前线程
			System.out.println(Thread.currentThread().getName() + " " + j);
			
			if(j == 10) {
				//创建并启动第一个线程
				new ExtendThread().start();
				
				//创建并启动第二个线程
				new ExtendThread().start();
			}
		}
	}
 
	public void run() {
		for(;i < 100;i++) {
			//当通过继承Thread类的方式实现多线程时,可以直接使用this获取当前执行的线程
			System.out.println(this.getName() + " "  + i);
		}
	}
}

代码相关:

1、上述的getName()方法是返回当前线程的名字,也可以通过setName()方法设置当前线程的名字;

2、当JAVA程序运行后,程序至少会创建一个主线程(自动),主线程的线程执行体不是由run()方法确定的,而是由main()方法确定的;

3、在默认情况下,主线程的线程名字为main,用户创建的线程依次为Thread—1、Thread—2、…、Thread—3;

4、线程的执行是抢占式,并没有说Thread-0 或者Thread-1一直占用CPU(这也与线程优先级有关,这里Thread-0 、Thread-1线程优先级相同,关于线程优先级的知识这里不做展开);

2.3 实现Callable和Future接口

通过这两个接口创建线程,你要知道这两个接口的作用,下面我们就来了解这两个接口:通过实现Runnable接口创建多线程时,Thread类的作用就是把run()方法包装成线程的执行体,那么,是否可以直接把任意方法都包装成线程的执行体呢?从JAVA5开始,JAVA提供提供了Callable接口,该接口是Runnable接口的增强版,Callable接口提供了一个call()方法可以作为线程执行体,但call()方法比run()方法功能更强大,call()方法的功能的强大体现在:

1、call()方法可以有返回值;

2、call()方法可以声明抛出异常;

从这里可以看出,完全可以提供一个Callable对象作为Thread的target,而该线程的线程执行体就是call()方法。但问题是:Callable接口是JAVA新增的接口,而且它不是Runnable接口的子接口,所以Callable对象不能直接作为Thread的target。还有一个原因就是:call()方法有返回值,call()方法不是直接调用,而是作为线程执行体被调用的,所以这里涉及获取call()方法返回值的问题。

于是,JAVA5提供了Future接口来代表Callable接口里call()方法的返回值,并为Future接口提供了一个FutureTask实现类,该类实现了Future接口,并实现了Runnable接口,所以FutureTask可以作为Thread类的target,同时也解决了Callable对象不能作为Thread类的target这一问题。

实现步骤:

1、创建Callable接口实现类,并实现call()方法,该方法将作为线程执行体,且该方法有返回值,再创建Callable实现类的实例;

2、使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值;

3、使用FutureTask对象作为Thread对象的target创建并启动新线程;

4、调用FutureTask对象的get()方法来获得子线程执行结束后的返回值

在Future接口里定义了如下几个公共方法来控制与它关联的Callable任务:

1、boolean cancel(boolean mayInterruptIfRunning):试图取消Future里关联的Callable任务;

2、V get():返回Callable任务里call()方法的返回值,调用该方法将导致程序阻塞,必须等到子线程结束以后才会得到返回值;

3、V get(long timeout, TimeUnit unit):返回Callable任务里call()方法的返回值。该方法让程序最多阻塞timeout和unit指定的时间,如果经过指定时间后,Callable任务依然没有返回值,将会抛出TimeoutException异常;

4、boolean isCancelled():如果Callable任务正常完成前被取消,则返回true;

5、boolean isDone():如果Callable任务已经完成, 则返回true;

public static void main(String[] args) {
    FutureTask<Integer> task = new FutureTask<Integer>(() -> {
        int i = 0;
        for (i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
        return i;
    });
    for (int i = 0; i < 10; i++) {
        System.out.println(Thread.currentThread().getName() + " " + i);
        if (i == 1){
            new Thread(task,"call").start();
        }
    }
    try {
        System.out.println(task.get());
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }
}

1、上述代码没有使用创建一个实现Callable接口的类,然后创建一个实现类实例的做法。因为,从JAVA8开始可以直接使用Lambda表达式创建Callable对象,所以上面的代码使用了Lambda表达式;

2、call()方法的返回值类型与创建FutureTask对象时<>里的类型一致。

2.4 三种创建线程方式对比
通过继承Thread类创建线程:

优点:

  1. 实现简单
  2. 获取当前线程时,直接使用this,不需要使用Thread.currentThread()

缺点:

  1. 线程类已经继承Thread就不能继承其他类
  2. 多个线程不能共享同一资源(如代码中i)
通过实现Runable或Callable接口创建线程:

优点:

  1. 每个类只是实现接口,还可以继承其他类
  2. 可以共享同一资源,适用于多个线程处理同一份资源

缺点:

  1. 较第一种方法复杂
  2. 想要访问当前线程,需要使用Thread.currentThread()

3. 线程状态及其转换

线程状态

在JDK中Thread中存在一个枚举类State,定义了线程状态

public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}
新建状态

用new语句创建的线程就处于新建状态,和Java对象一样,仅在堆中分配了内存,在调用start之前,就处于新建状态

就绪状态

在一个线程对象创建之后,调用它的start方法,该线程就进入就绪状态,JVM为线程创建栈和程序计数器。处于这个状态的线程位于可运行池中,等待获取CPU的执行权

运行状态

处于该状态的线程获取了CPU的执行权,执行程序代码,只有处于就绪状态的线程才有机会到运行状态

阻塞状态

在线程期望进入同步代码块或者同步方法中时会进入该状态(Synchronized或者Lock锁),等待资源(非CPU资源),等待到了资源线程就会进入就绪状态等待CPU调度

等待状态

一般是调用wait方法会进入等待状态,当线程被其他的调用notifyAll或者notify的线程通知唤醒,才会从等待状态进入阻塞状态(还需要获取锁)

超时等待

如果线程执行了sleep(long time),wait(long time),join(long time),设置等待时间,当等待时间到了,就脱离阻塞状态

终止状态

线程退出run(),就进入到该状态,生命周期结束

线程方法
start():启动线程

start():作用是启动一个新线程,需要首先调用,start方法是不能重复调用的

public synchronized void start() {
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

       //线程组概念。已经被线程池替代
        group.add(this);

        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
            }
        }
    }

    private native void start0();

start方法启动子线程是通过调用native的系统提供的方式来启动线程

run():子线程执行体

作用是将所有的子线程业务逻辑在run方法中实现,在调用start方法时,会启动子线程并主动调用到run方法。不需要显性调用。

单独调用run()方法,会在当前线程中执行run()方法,并不会启动新线程

yield():线程让步

作用:是暂停当前的线程的执行,并让步于其他同优先级或更高线程,让其他线程先执行

public static native void yield();

方法特点:

1、yield方法是Thread类的静态方法,通过Thread.yield()调用

2、能够让正在执行的线程由“运行状态”进入到“就绪状态”,等待CPU调度。

3、正在执行线程让步与相同优先级或更高优先级的线程来获取CPU执行权,当同级优先级或者更高优先级没有对应线程时,当前线程又可以继续执行

sleep():线程休眠

作用:让线程休眠,而且是哪个线程调用sleep方法,哪个线程休眠。

方法试下如下:

public static native void sleep(long millis) throws InterruptedException;

public static void sleep(long millis, int nanos) throws InterruptedException {
        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos >= 500000 || (nanos != 0 && millis == 0)) {
            millis++;
        }

        sleep(millis);
    }

sleep方法有两个,分别是sleep(long millis)和sleep(long millis, int nanos),两个方法功能一样,第二个方法提供了纳秒级的时间控制

特点:

1、sleep方法是静态的native方法,调用Thread.sleep()调用

2、sleep方法调用会使当前线程从“运行状态”进入到“睡眠状态”,直到给定的时间millis,当前线程才会被唤醒

3、sleep方法会使线程休眠到指定的时间,如果要提前终止休眠,可以通过Interrupt方法调用,会使当前的sleep方法抛出InterruptedException异常,结束掉休眠

join():线程合并

作用:暂停当前线程,等待子线程执行结束当前线程才能执行。join方法让并行的线程合并为串行的线程执行

例如:a线程中执行代码b.join()方法,则a线程会停止当前执行,并让b线程线执行,直到b线程执行结束,a线程才继续执行

join方法实现:

//指定合并等待时间
public final synchronized void join(long millis) throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

 //指定毫秒、纳秒界别的合并时间
    public final synchronized void join(long millis, int nanos) throws InterruptedException {

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos >= 500000 || (nanos != 0 && millis == 0)) {
            millis++;
        }

        join(millis);
    }

    //会一直等待直到子线程执行结束
    public final void join() throws InterruptedException {
        join(0);
    }

方法特点:

1、线程方法提供了三种方法join(long millis)、join(long millis, int nanos)和join()方法,都是调用到join(long millis)的实现

2、join方法定义在Thread类中,可以抛出InterruptedException异常,即join方法是可以被中断合并

3、join方法调用会使线程状态从“运行状态”进入到“阻塞状态”

4、join方法可以使线程按照顺序串行执行

给定三个线程,线程名分别为A、B、C。每个线程业务是打印名称,要求打印结果为ABC

思路:根据问题分析:线程执行顺序必须控制为A线程执行,B线程执行,C线程执行

A和B线程:在B线程中调用A.join(),同理:B和C线程,在C线程中调用B.join()

public class ABCThread extends Thread {
    private Thread thread;//线程实例
    private String name;//线程名

    public ABCThread(Thread thread,String name) {
        this.thread = thread;
        this.name = name;
    }

    @Override
    public void run() {
        try {
            //控制当前线程晚于thread子线程结束
            if (thread != null) thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //打印当前线程名
        System.out.print(name);
    }

    public static void main(String[] args) {
        ABCThread a = new ABCThread(null, "A");
        ABCThread b = new ABCThread(a, "B");//在b线程中a.join
        ABCThread c = new ABCThread(b, "C");//在c线程中b.join
        a.start();
        b.start();
        c.start();
    }
}
Interrupt():中断线程

作用:中断当前线程,中断处于阻塞状态的线程

方法:

boolean isInterrupted() 返回值:true:当前线程被中断  false:当前线程未被中断
void interrupt();发起中断操作 是Thread类中的普通方法,由对象调用该方法
public void interrupt() {
        if (this != Thread.currentThread())
            checkAccess();

        synchronized (blockerLock) {
            Interruptible b = blocker;
            if (b != null) {
                interrupt0();           // Just to set the interrupt flag
                b.interrupt(this);
                return;
            }
        }
        interrupt0();
    }
private native void interrupt0(); //底层操作系统提供中断业务

方法说明:

中断操作interrupt()方法底层调用的是native的interrupt0()方法,发起中断操作仅仅修改中断标志位

1、如果当前的线程处于阻塞状态(sleep、join、wait等方法会导致线程进入阻塞状态),在任意的其他线程调用Interrupt()方法,那么线程会立即抛出一个InterruptedException退出则塞状态

2、如果当前的线程处于运行状态,。其他线程调用Interrupt方法,当前线程继续运行,直到发生了阻塞之后随后会立即抛出异常跳出则塞

deamon:守护线程

方法介绍:

void setDaemon(boolean on) 设置守护线程, 参数Boolean类型 true:设置为守护线程 false:非守护线程 默认是false

boolean isDaemon() 判断当前线程是否为守护线程

守护线程:

Java中有两种线程,用户线程和守护线程,通过isDaemon方法进行区分,如果返回是false:说明线程是用户线程,否则是”守护线程“

用户线程一般用户执行用户级的任务

守护线程也称之为”后台线程“,服务于用户线程,一般执行后台任务,例如:垃圾回收的线程是单独线程来处理的,负责垃圾回收的线程就是守护线程

线程生命周期:

守护线程是依赖于用户线程,当用户线程存在时,守护线程就会存活,当没有了用户线程时,那么守护线程也就会随之消亡

Priority:线程优先级

就是用来指导线程的执行优先级的,指的是用来告诉CPU那些线程优先被执行

方法介绍

void setPriority(int newPriority) //设置当前线程的优先级,newPriority必须是1~10之间的整数,否则会抛出异常

int getPriority() //获取当前线程的优先级

public final static int MIN_PRIORITY = 1; //最小优先级*
* public final static int NORM_PRIORITY = 5;//默认优先级*
* public final static int MAX_PRIORITY = 10;//最大优先级

优先级的特点:

1、Java的线程的优先级并不是绝对的,其控制的是执行的机会,优先级高的线程被执行的概率比较大,而优先级低的线程也并不是没有机会,只是执行的概率相对低一些

2、Java线程的优先级一共10个级别,分别为1-10,数值越大,表明优先级越高,一个普通的线程,其优先级为5

线程调度
方法级的调度

Java线程中提供的一些方法可以进行线程的调度,合理的线程方法使用,可以充分发挥系统的性能,提高程序的执行效率

  1. 线程的优先级:高优先级线程获得较多的运行机会
  2. 线程睡眠:sleep().让当前线程从运行状态进入到阻塞状态
  3. 线程让步:yield(),让正在执行的线程从运行状态进入就绪状态
  4. 线程合并:join(),当前调用join方法所在的线程进入到阻塞状态
  5. 线程等待:wait(),就会是线程从运行状态进入到等待状态
系统级调度

指系统在特定的实际自动进行调度,主要涉及调度算法

FIFO:先进先出

SJF:短作业优先

SRTF:最短剩余时间优先算法

RR:时间片轮转算法

HPF:最高优先级算法

OS系统调度算法一般都是基于时间片轮转的优先级算法

4. 并发

并发和并行的区别

并发是指多个线程操作同一资源,不是同时执行,而是交替执行,单核CPU,时间片很短,执行速度很快,看起来像是同时执行

并行才是真正的同时执行,多核CPU,每个线程使用一个单独的CPU资源来运行

并发作用:可以最大化的提高计算机资源利用效率

临界资源和临界区

临界资源:一个时刻只能允许一个线程访问,一个进程正在使用的资源叫做临界资源,这种资源需要保证互斥访问

临界区:指线程中访问临界资源的程序代码,注意:是指程序代码,而不是内存资源或CPU资源。使用原则:空闲让进、忙则等待、有限等待、让权等待

空闲让进:临界资源在空闲时一定要让线程进入

忙则等待:临界资源正在使用时,其他线程则需要等待

有限等待:等待进入临界区的时间是有限的,不能无线等待

让权等待:线程进入临界区应该放弃CPU资源

5. java内存模型

CPU和缓存一致性

为什么会出现缓存一致性的问题?

CPU的执行效率和内存读取速度差距越来越大,导致每次操作内存时需要大量时间等待,为了解决这个问题,就提出了高速缓存,介于内存和CPU之间,在内存中获取的数据会拷贝一份到高速缓存中,CPU对数据处理的中间数据会存放在高速缓存中,当运行结束,将高速缓存中数据写入到主内存。

在多线程环境下,每个线程都拥有自己的缓存,关于同一个线程共享数据的缓存的内容就不一致了

在这里插入图片描述

处理器优化和指令重排

处理器的优化

int a = 11;

int b = 12;

a++;

1. 加载变量a赋值为11,放在寄存器
2. 将a写回主内存,加载变量b赋值为12
3. 将b写回主内存,加载变a,完成++操作

1. 加载变量a赋值为11,放在寄存器
2. 完成++操作,将a写回主内存
3. 加载变量b赋值为12,将b写回主内存

为了使处理器运算单元充分利用,处理器可能会对程序代码进行乱序处理,这个就是处理器优化

指令得到的优化

优化也是对代码进行乱序处理。很多编程语言的编译器都会有类似的优化,例如:JVM即时编译器(JIT)也会进行类似的指令重排序

static Integer num = null;
......
num = 10;
优化后 static Integer num = 10;
并发编程带来的问题

原子性问题、有序性问题、可见性问题

这些问题造成的原因是:缓存一致性、处理器优化、指令重排

原子性

即一个操作或多个操作,要么全部执行,并且执行过程中时不会被任何元素打断的,要么全不执行。

例如i++操作:

i++主要有三步操作:

  1. 读取i变量的值
  2. 进行+1操作
  3. 将新的值赋值给变量i

当t1和t2线程同时执行时,这三步中任何一步都有可能被并行的其他线程调用覆盖数据,存在丢失部分数据更新的情况

可见性

是指当多个线程访问同一个变量时,一个线程的修改能够被其他线程立刻看到修改的结果值。

导致线程可见性问题的原因是由于缓存的存在,线程持有的共享变量副本的修改,无法让其他线程感知到变量的修改,导致读取的值不是最新的

有序性

指程序执行顺序不是按照代码顺序的先后顺序执行

java的内存模型

分为主内存和工作内存

主内存:所有的变量都存储在主内存中

工作内存:每个线程自己的内存区域

工作内存保证了该线程使用到变量的主内存的副本。线程对变量的操作(读取、复制)都需要在工作内存中进行,而不能直接读取主内存的数据

不同的线程之间无法直接访问对方的工作内存,线程间变量的传递均需要用到主内存来完成
在这里插入图片描述

主内存和工作内存之间的交互的操作:

  1. lock(锁定) :作用于主内存的变量,它把一个变量标识为一条 线程独占 的状态。
  2. unlock(解锁) :作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取) :作用于主内存的变量,它把一个变量的值 从主内存传输到线程的工作内存 中,以便 随后的load动作 使用。
  4. load(载入) :作用于工作内存的变量,它把read操作 从主内存中得到的变量值放入工作内存 的变量副本中。
  5. use(使用) :作用于工作内存的变量,它把 工作内存中一个变量的值传递给执行引擎 ,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值) :作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量 ,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储) :作用于工作内存的变量,它把 工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  8. write(写入) :作用于主内存的变量,它把store操作 从工作内存中得到的变量的值放入主内存的变量中。

在这里插入图片描述

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意, Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。 也就是说 read与load之间、store与write之间是可插入其他指令的 ,如对主内存中的变量a、b进行访问时, 一种可能出现的顺序是read a、read b、load b、load a 。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

不允许read和load、store和write操作之一单独出现

不允许一个线程丢弃它最近的assign操作

不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中

不允许在工作内存中直接使用一个未被初始化(load或assign)的变量

只允许一条线程对其进行lock操作

6. volatile关键字

使用volatile关键字姿势的变量,线程在每次使用这个变量的时候。都会去读取变量修改后的最新值

场景问题:统计1秒内num++的次数

解决思路:创建AB两个线程,A线程负责计数,B线程负责睡眠1秒后通知A线程停止

实现代码

public class ThreadTest {
    static boolean flag = true;

    public static void main(String[] args) {
        Thread thread1 = new Thread(new Runnable() {
            int num = 0;

            @Override
            public void run() {
                while (flag) {
                    num++;
                }
                System.out.println(num);
            }

        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    flag = false;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

在当前线程执行过程中,一个线程没有感知到标志位的修改,而程序仍然会执行

在JVM运行的时候,在两个线程使用共享变量flag时,每个线程都会获取当前变量的副本保存到自己的私有内存区域即虚拟机

thread1线程在堆变量进行修改时,在thread1线程的本地中赋值的最新值就没有即使store到主内存,那么thread就不能读取到最新的flag的变化,因此thread仍然继续执行

在flog变量添加上volatile关键字以后,thread1线程的修改操作能够立即引起thread线程停止循环

volatile特征
禁止指令重排序

volatile修饰的变量的操作顺序与代码中的执行顺序一致

普通变量仅会保证方法执行的最终结果正确,不能保证变量的操作顺序和代码中的执行顺序一致

保证线程间的可见性

保证变量对所有线程的可见性,“可见性”是指当一个线程将其修改时,新值对于其他线程可以立即感知到

注意:volatile修饰的变量可以保证有序性、可见性,不能保证原子性,也不能保证线程安全

volatile工作原理
“观察加入了volatile关键字和没有加volatile关键字时锁生成的汇编代码,加入了volatile关键字会多出一个Lock前缀”																						           --《深入理解java虚拟机》
该内存屏障提供了3个功能:
1. 他确保指令重排时,不会把指令之前的代码重排到屏障之后,同理,也不会把指令之后的代码重排到屏障之前
2. 它会强制将对缓存的修改操作立即写会主内存
3. 如果是写操作,会导致其他CPU上对应的缓存行无效(缓存一致性)
如何保证可见性?

变量被volatile修饰

当一个共享变量被volatile修饰时,会保证修改时的值立即写会主内存,当其他线程读取该值时,不会直接读取工作内存中的值,而是直接去主内存中去读

被volatile修饰的变量在每个写操作之前会加入一条store内存屏障命令,会强制将此变量的最新值同步到主内存,在每个读操作之前都会加入一个load屏障,会强制将此变量的最新值从主内存加载到工作内存

变量未被volatile修饰

普通变量不能保证其可见性,因为普通变量在被修改后,写入到工作内存,写回主内存是不可知的,当其他线程读取时,无论是工作内存还是主内存,都不是最新值

如何保证有序性?

通过内存屏障来解决多线程下的有序性问题,编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器指令重排

在每个volatile写操作的前面插入一个StoreStore屏障

在每个volatile写操作的后面插入一个StoreLoad屏障

在每个volatile读操作的前面插入一个LoadLoad屏障

在每个volatile读操作的后面插入一个LoadStore屏障

写操作之前后插入内存屏障后生成指令序列的示意图

在这里插入图片描述

读操作之后插入内存屏障后生成指令序列的示意图
在这里插入图片描述

应用场景

Boolean类型的共享标志位

单例模式下的双重检验锁

注意:

volatile对于基本数据类型才用

对于对象来说,是没有作用的,volatile只能保证对象引用的可见性,对于对象内部的字段修改,他无法保证可见

7. 锁及其锁优化

乐观锁和悲观锁
悲观锁

总是假设最坏的情况,每次获取共享数据时都会认为被别人修改,因此每次获取数据时都会加锁。传统的关系型数据库很多时候都会使用到这种锁,比如行锁,表锁(如拿行1/表1的数据时,都会对其加锁,同一时刻只能有一个操作进行),写锁,都是在操作钱加锁,再比如java中的Synchronized关键字也是悲观锁

存在的问题:

  1. 在多线程竞争中,加锁、释放锁会导致大量的上下文切换、调度延迟,引发性能问题
  2. 一个线程持有锁,会导致其他需要此锁的线程挂起
乐观锁

认为数据一般情况下不会产生冲突,所以在数据进行提交更新时,才会对数据是否产生冲突进行检测,如果产生冲突,则返回错误信息,由用户进行 决定如何操作

乐观锁的实现典型是CAS

CAS

CAS是乐观锁的实现技术,当多个线程尝试使用CAS同时更新同一个变量,只有一个线程能更新变量的值,而其他线程都会失败,失败的线程不会被挂起,而是被告知这次竞争失败了,并可以再次尝试

CAS操作中涉及到三个操作数:

需要写入的位置(V)

需要比较的预期原值(A)

拟写入的值(B)

如果内存位置V与预期原值A相匹配,那么处理器会自动的将该位置的值改变为B,否则处理器不做任何处理。

第一步:获取位置V的值A

第二步:将获取的值A与位置V的值进行比较

如果相等,则认为没有其他线程进行修改,即不存在线程竞争,就可以将新值B写入位置V

如果不相等,就认为有其他线程对该位置进行并发操作,不能直接修改,继续进行第一步,获取V位置的值A,在进行比较,直至相 等时进行修改

模拟CAS操作
public class CompareAndSwap {
    private int value;

    /**
     * 获取value
     * @return value
     */
    public int getValue() {
        return value;
    }

    /**
     * 比较并交换,只有预期值和现在的值相同时才会成功
     * @param expectVal
     * @param newVal
     * @return
     */
    public synchronized int compareAndSwap(int expectVal,int newVal){
        int oldVal = value;
        if (oldVal == expectVal){
            this.value = newVal;
        }
        return oldVal;
    }

    public synchronized boolean compareAndSet(int expectVal,int newVal){
        return compareAndSwap(expectVal,newVal) == expectVal;
    }

}

public class TestCompareAndSwap {
    public static void main(String[] args) {
        CompareAndSwap compareAndSwap = new CompareAndSwap();
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    int value = compareAndSwap.getValue();
                    Random random = new Random();
                    int newVal = random.nextInt(100);
                    boolean b = compareAndSwap.compareAndSet(value, newVal);
                    System.out.println(Thread.currentThread().getName() + " 预期值:"+
                            value + " 待写入值:"+newVal +" 操作结果:"+b);
                }
            }).start();
        }
    }
}

在JDK1.5中新增了java.util.concurrent(J.U.C)建立在CAS之上,相较于synchronized是一种线程阻塞处理,而CAS是一种常见的fail阻塞的实现,即线程即使没有获取到变量也不会进入到阻塞状态。就是在不使用锁的情况下保证线程安全,在JUC存在如AtomiInteger为例,其中一些++i操作是安全性操作,getAndIncrement()方法

CAS中的ABA问题

假设出现如下执行序列:

  1. 线程1从内存V取出A
  2. 线程2从内存V取出A
  3. 线程2进行了一系列操作,将位置V修改为B
  4. 线程2将A写入位置V
  5. 线程1进行CAS操作,发现位置V依然是A,写入成功

尽管线程1任然写入成功,但是不代表这个过程中没有问题,对于线程1而言线程2的修改已经丢失了

1.ABA问题

引入版本号

2.循环时间开销大

自旋CAS操作如果长时间不成功,对CPU会带来非常大的开销

3.只能保证一个共享变量的原子操作

当对一个共享变量进行操作时,可以使用CAS操作,对于多个共享变量CAS就不能保证原子性,就需要使用到锁

Synchronized锁优化

在JDK1.5之前,synchronized的底层实现都是重量级的,也称synchronized为重量级锁,在JDK1.5之后,对synchronized进行了各种优化,实现的原理是锁升级的过程。偏向锁,轻量级锁和重量级锁。

java对象的内存布局

在这里插入图片描述

在java中创建一个对象,在JVM中,对象在内存中的存储布局分为一下三块:

对象头区域:存放锁信息、对象年龄等信息

实例数据区域:存储的是对象中真正有效的数据,比如对象中所有字段内容

对其填充部分:JVM规定了对象的起始必须是8字节的整数倍,如果前两个区域的大小不满足8的整数倍,就补位到8的整数倍。对齐区域大小不是固定的

synchronized用到的锁就存在对象头中,如果对象是数组类型,对象头中还包含了数组长度

Mark Word

指向类指针

数组长度(只有数组类型才有)

如果是数组类型,则虚拟机使用3个字宽来存储对象头,如果不是数组类型。则占用2个字宽(在32位系统下,1个字宽4个字节32个bit位)

在java对象头中,Mark Word默认存储对象的Hashcode、分代年龄、锁的标记位,32位JVM中默认存储结构如下所示:

锁状态25bit4bit1bit2bit
23bit2bit是否偏向锁锁标志位
无锁对象的HashCode分代年龄001
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
CG标记11
偏向锁线程IDEpoch分代年龄101

在java SE1.6中,锁一共存在4中状态,从低到高分别为:无锁状态、偏向锁、轻量级锁、重量级锁。这几个状态随着竞争情况进行升级,但不会降级,意味着偏向锁升级成轻量级锁在升级成重量级锁,目的是为了获取锁和释放锁的效率

偏向锁

偏向锁是无需操作系统介入的,每个对象都有对象头,对象头的Mark Word存储对象的锁信息

在这里插入图片描述

该对象头先处于无锁状态,当有线程来访问,JVM使用CAS操作将线程ID记录到Mark Word中,修改偏向锁的标识位,就说明当前线程拥有了这把锁
在这里插入图片描述

注意:将线程ID通过CAS记录,变更偏向锁标识位1,

JVM不需要和操作系统协商,只需要记录下线程ID,就表示当前线程拥有了这把锁,不用操作系统介入

获取锁的线程就可以进入到程序代码块中,当线程再次执行时,JVM通过这把锁对象的MarkWord判断,如果当前线程ID还存在,还持有这个对象的锁,就直接进入到临界区执行,在没有别的线程竞争时,一直偏向当前的线程可以一直执行。

优点:只有一个线程执行同步代码块时可以进一步提高性能,适用于同一线程反复获取同一把锁的情况,偏向锁可以提高带有同步但无竞争的程序性能

轻量级锁

如果在偏向锁中一个线程A一直执行过程中又来了另一个线程B要进入代码块进行执行,但是锁对象保存的是线程A的线程ID,还是偏向锁,就会导致线程B无法执行,这时就需要进行锁的升级,编程一个轻量级锁

JVM把锁对象恢复成无锁状态,将线程ID清除掉,在当前两个线程的栈帧中开辟一个空间,叫做Lock Record,把锁对象的信息的Mark Word在两个线程的栈帧中各复制一份,叫做Displaced Mark Word,将当前线程A的Lock Record的地址使用CAS放到锁对象的MarkWord中,并将锁的标识设置为00,意味着当前线程A获取到轻量级锁,可以进入到临界区执行,但线程B没有获取到锁,但不阻塞,JVM会让它自旋几次,等待,当线程A退出了临界区释放锁的时候,需要将Displaced Mark Word使用CAS复制回去,接下来线程B就可以通过通过CAS复制信息。这个时候两个线程就可以交替进入临界区,执行代码

在这里插入图片描述

轻量级锁即使出现了线程竞争,想获取锁只需要自旋几次,等待一会,锁就会释放,使用CAS和Lock Record就可以避免重量级锁的开销

优点:在绝大部分的锁在整个生命周期中都存在少量竞争,在多线程交替执行同步代码块时可以避免重量级锁引起的性能问题

重量级锁

轻量级锁在运行时,线程A持有锁,线程B自旋了很多次,线程A还没有释放锁,JVM考虑自旋次数太多浪费CPU资源,就需要将锁升级为重量级锁

重量级锁需要操作系统的介入,依赖操作系统底层的mutex lock,JVM会创建monitor对象,把这个对象的地址信息更新到Mark Word中,并将锁标志置为10

在这里插入图片描述

线程A还持有这把锁,线程B直接挂起,线程进入阻塞,释放掉占用的CPU资源

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值