面向对象-Unit2-Java多线程
进程和线程
Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()
方法,在main()
方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。
因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。
和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。
多线程并发的法宝:外互斥、内可见
内存模型
每个进程会把虚拟内存空间分成4个段(代码段, 数据端,堆,栈)
代码段:用来存放进程(应用App)的代码指令。
数据端:用来存放全局变量的内存。
堆:调用os的malloc/free 来动态分配的内存。
栈:用来存放局部变量,函数参数,函数调用与跳转。
每个进程(应用App)相当于一个容器,所有应用App里面需要的资源和机制都在进程里面。
线程是OS独立调度执行的单元,OS调度执行的单位就是线程,线程需要以进程作为容器和使用进程相关的环境。
每个线程共享进程的代码段内存空间,所以我们编写多线程代码的时候,可以在任何线程调用任何函数。
每个线程共享进程的数据段内存空间,所以我们编写多线程代码的时候,可以在任何线程访问全局变量。
每个线程共享进程的堆,所以我们编写多线程代码的时候,可以在一个线程访问另外一个线程new/malloc出来的内存对象。
每个线程都有自己的栈的空间,所以可以独立调用执行函数(参数,局部变量,函数跳转)相互之间不受影响。
并行程序中函数是以怎样形式存在、怎样执行,其实是一个关键的问题
OS调度
CPU有几个核心,就可以真正并行地执行几个线程。OS的功能就是要在合适的时候分配CPU核心来调度合适的线程。
OS会根据线程的优先级分配每次调度最多执行的时间片,这个时间一到,无论如何都要重新调度一次线程。
除了时间片以外,线程会等待某些条件(磁盘读取文件,网卡发送完数据,线程休眠, 等待用户操作)这样也会把这个线程挂起,OS会重新找一个新的线程继续执行,只到挂起的这个线程的条件满足了,重新把这个线程放到可调度队列里面,这个线程又有机会被OS调度CPU核心来执行。
每个线程都会有一个运行时的环境
- 运行时CPU的每个寄存器的值、栈独立。
- 栈的内存数据不会变。
- 数据段、堆共用,可能调度回来会变
当OS要把某个CPU核心调度出去给其它线程的时候,首先会把当前线程的运行环境(寄存器的值等)保存到内存,然后调度到其它线程,等再次调度回来的时候,再把原来保存到内存的寄存器的值,再设置会CPU核心的寄存器里面,这样就回到了调度出去之前的进度。
因为多线程之间共用了代码段(代码段只读,不会改),数据段(全局变量调度回来后,可能被其它线程篡改,不是调度之前的那个值了),堆(调度回来后,动态内存分配的对象内存数据可能被其它线程出篡改),调度回来后,栈上的数据是不变的,因为每个线程都有自己的栈空间。线程调度前后哪些会变,哪些不变你要清楚。这样你写多线程代码的时候才能清晰。
线程调度的开销就是:保存上下文执行环境,内核态运行算法决定接下来调度那个线程,切换这个线程的上下文环境。
多线程的顺序
操作系统会将CPU资源切分为若干个时间片,采用时间片轮转的方法将时间片分给线程,线程分配到时间片后在CPU上运行,没有分配到时停滞。
通俗来讲,线程A在CPU上执行,其他线程停滞。到点以后(时间片结束)就轮到另一个线程B在CPU上执行,B以外的线程都停滞。以单核CPU为例,由于时间片通常很小,尽管同一时间只会有一个线程在运行,但因为运行线程“换的太快”,宏观上却感觉多个线程在同时执行。
对于单线程程序(顺序执行),程序的执行主体只有一个,它的执行顺序是确定的,且程序的运行结果也一定是确定的,不管运行多少次只要输入一样,输出结果一定相同。
多个线程组成的程序就是多线程程序,有多个执行程序的主体,各个线程的执行相对独立(并发执行)。
时间片相对于程序员透明(我们并不能知晓一个线程执行到某个确定的地方时,另一个线程执行到了哪里),各个线程的相对执行顺序不确定,运行结果也可能随之不确定。
Java内存模型
JMM
Java线程间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见。
JMM是一个抽象的概念,并非真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器的优化
JMM定义了线程和主内存之间的抽象关系:
-
线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)
-
每个线程都有一个私有的本地内存,本地内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)
-
同时JVM通过JMM来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
-
重要声明: JMM所描述的主内存、工作内存与Java内存区域的堆栈不是一回事,更准确是主内存就是内存条,为了提高性能,JVM可能会让工作内存优先存储在寄存器和高速缓存中,程序运行时主要访问读写的也是工作内存
JMM的关键技术点都是围绕多线程的原子性、可见性和有序性展开的
- 原子性:一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰
- 可见性:当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更
- Java中普通的共享变量不保证可见性,因为其的修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读"
- 缓存优化或者硬件优化或指令重排以及编辑器的优化都可能导致一个线程修改不会立即被其他线程察觉
- Java提供
volatile
保证可见性:写操作立即刷新到主内存,读操作直接从主内存读取 - Java同时还可以通过加锁的同步性间接保证可见性:
synchronized
和Lock
能保证同一时刻只有一个线程获取锁并执行同步代码,并在释放锁之后将变量的修改刷新到主内存中
- 有序性:
- 对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序
- 指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读"
可见性保证
- 正确同步的多线程程序: 将顺序一致性,JMM通过限制重排序来为程序提供内存可见性保证
- 未同步/未正确同步的多线程程序: JMM提供最小安全性保障-线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值
重排序
写后写、写后读、读后写均存在相关数据依赖,不允许重排序
- **数据依赖性:**若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性
- 编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行
- 但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境
Java多线程
Java的多线程编程不能像C、Cuda计算中那样精细地手动掌控线程、内存的运行,而是由Java虚拟机和OS进行自动调控。在Java多线程编程中,能做的、需要做的就是宏观地开启线程、对资源进行互斥和共享判断,其余地由OS自动完成。
基本函数
封装好了Thread类,只要向其中送入需要执行的参数即可。
基本路线:
- 创建子类:创建一个新的类,该类继承自Thread类
- 重写
run()
方法:在子类中重写Thread类的run()
方法,该方法定义了线程的执行任务。 - 实例化子类对象:创建子类的对象,即线程对象。
- 调用
start()
方法:调用线程对象的start()
方法,启动新线程,新线程会执行run()
方法,从run()
方法退出后被回收。 - 线程执行:一旦调用
start()
方法,线程会被加入到线程调度器中,等待分配CPU资源,当线程获得CPU资源时,就会执行其run()方法中定义的任务。
run()
方法只是主线程调用了一个Test对象的public方法,执行这个方法的仍然是主线程,并没有产生新的线程
start()
方法却是新创建了一个线程来执行对应的run()
方法,原来的线程继续向下执行。
直接调用run()
方法是无效的,相当于调用了一个对象的方法,并没有创建真正的线程。只有采用start()
方法,才会创建一个线程,继而自动调用run()
方法
创建线程
创建线程的几种方式:
-
继承Thread类,重写
run()
方法1
2
3
4
5
6
7
8
9
10
11
12
13public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}
class MyThread extends Thread {
public void run() {
System.out.println("start new thread!");
}
} -
创建
Thread
实例时,传入一个Runnable
实例1
2
3
4
5
6
7
8
9
10
11
12
13public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}
class MyRunnable implements Runnable {
public void run() {
System.out.println("start new thread!");
}
}也可以使用Lamba语法进行简写:
1
2
3
4
5
6
7
8public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
} -
实现Runnable接口
线程优先级
JVM自动把1(低)~10(高)的优先级映射到操作系统实际优先级上(不同操作系统有不同的优先级数量)。
优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但决不能通过设置优先级来确保高优先级的线程一定会先执行。
1 | Thread.setPriority(int n) // 1~10, 默认值5 |
资源竞争
原子性:一个操作或者多个操作,要么全部执行并且执行的过程不会被其他线程打断,要么就都不执行。
实现一组操作序列的原子性需要进行线程互斥(mutual exclusion):如果某一个线程正在对共享对象执行某种操作,那么其他所有线程都不能进行对该共享对象执行某种操作。
线程的状态
一个线程对象只能调用一次start()
方法启动新线程,并在新线程中执行run()
方法。一旦run()
方法执行完毕,线程就结束了。
Java种线程的状态共有6种。
-
初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
-
运行(RUNNABLE):将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
- 就绪:线程对象创建后位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。
- 运行种:就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
-
阻塞(BLOCKED):表示线程阻塞于锁。
阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。
-
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。
-
超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
-
终止(TERMINATED):表示该线程已经执行完毕。
进程的阻塞
有些进程的执行是有相对的先后顺序的,需要一个函数执行结束后再执行另一个,这个是否就可以使用join()
来对线程进行阻塞。
thread.join()
:阻塞,在该线程执行结束前不开启别的线程。通过对另一个线程对象调用join()
方法可以等待其执行结束;可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;
对已经运行结束的线程调用join()
方法会立刻返回。
join
方法的基本用法如下:
-
单个线程的
join
方法:1
2
3
4
5
6
7
8Thread thread = new Thread(new Runnable() {
public void run() {
// 线程执行的代码
}
});
thread.start(); // 启动线程
thread.join(); // 等待线程结束在这个例子中,主线程启动了一个新线程,并通过调用
join
方法等待新线程完成。如果新线程还没有结束,主线程将被阻塞,直到新线程执行完毕。 -
多个线程的
join
方法: 如果你有多个线程,并且希望主线程等待所有这些线程都完成,你可以对每个线程调用join
方法。1
2
3
4
5
6
7
8Thread thread1 = new Thread(...);
Thread thread2 = new Thread(...);
thread1.start();
thread2.start();
thread1.join();
thread2.join();在这个例子中,主线程等待
thread1
和thread2
都执行完毕后才继续执行。thread1
和thread2
是并行执行的,而不是顺序执行。这意味着一旦thread1.start()
和thread2.start()
被调用,两个线程几乎同时开始执行它们的任务(由操作系统的线程调度器控制)。每个线程的执行是独立的,并且它们的完成顺序是不确定的,这取决于它们各自的任务内容、执行时间以及系统资源等。join
方法在这里用于同步线程,确保主线程(执行这段代码的线程)在继续执行之前等待thread1
和thread2
完成它们的执行。这意味着主线程会等待thread1
完成,然后再等待thread2
完成。这并不改变thread1
和thread2
并行执行的特性,而是确保了在它们完成之前,主线程不会执行join
之后的代码。由于CPU极快的运行速度,判断
join
函数几乎是并行完成的,不用纠结具体的先后,总体效果就是等待thread1
和thread2
运行结束 -
带有超时的
join
方法:join
方法还有一个重载版本,允许你指定一个超时时间。如果在指定的时间内线程没有结束,调用线程将不再等待。1
2
3
4
5
6
7
8long timeout = 5000; // 5秒超时
if (thread1.isAlive()) {
thread1.join(timeout);
}
if (thread2.isAlive()) {
thread2.join(timeout);
}在这个例子中,如果
thread1
或thread2
在5秒内没有结束,主线程将不再等待它们。
join
方法是多线程同步的一个重要工具,它可以确保线程以一种可预测的方式协同工作。正确使用 join
方法可以避免常见的并发问题,如死锁和竞态条件。
临界
对于多线程程序,我们有临界资源和临界区两个概念:
- 临界资源:一次仅允许一个进程使用的共享资源。
- 临界区:每个进程中访问临界资源的那段程序称之为临界区。
常利用锁的机制来实现线程对于共享资源的互斥访问,通过锁可以让一段临界区代码同时只能有一个线程运行,也就是使得临界区代码的执行具有原子性。
资源是什么呢?
不要忘记Java是一门面向对象的语言,OO的目的也是面向对象,线程、锁面对的资源都是对象,获取的是对象的锁。
一个对象的方法种可以有很多个加上关键字synchronized,但是在实际的运行种,都是同一个锁,即这个对象的锁,看哪个线程能优先获得这个对象的执行权限。
Java的锁有很多种,先介绍synchronized 关键字的一些用法。
当使用synchronized修饰某段代码时,某段代码就具有原子性。
Java中每个对象都与一个内置的锁关联,一个锁一次只能被一个线程持有(排他性)。获得锁的线程可以继续向下运行;
在持有锁的线程离开这段代码之前,任何程序由于无法获得锁而阻塞在这个锁的等待队列中
当持有锁的线程走出临界区后,会释放这个锁同时唤醒这个锁阻塞队列中的线程。抢夺到锁的线程可以运行临界区代码,而其他线程继续阻塞。
锁
synchronized
是Java中的一个关键字,用于创建一个原子操作,确保同一时刻只有一个线程能够执行特定的代码块或者方法。synchronized
可以用于修饰方法或者代码块,它提供了一种内置的锁机制,用于控制对共享资源的并发访问。
- 线程A释放一个锁,实质是线程A告知下一个获取到该锁的某个线程其已变更该共享变量
- 线程B获取一个锁,实质是线程B得到了线程A告知其(在释放锁之前)变更共享变量的消息
- 线程A释放锁,随后线程B竞争到该锁,实质是线程A通过主内存向线程B发消息告知其变更了共享变量
修饰方法
当在方法声明前使用 synchronized
关键字时,锁定的是当前对象的实例(面向对象中方法是依赖于对象的)(对于静态方法则是锁定的是这个类的所有对象)。任何时候,只有一个线程能够执行这个 synchronized
方法。
1 | public class Counter { |
在这个例子中,increment
和 getCount
方法都被声明为 synchronized
,它们将对 count
变量的访问同步化。
这意味着,即使有多个线程同时调用 increment
方法,谁获取了Counter实例化后的对象,谁才能调用相关的方法,count
变量的增加操作也是原子的,线程安全的。
对于静态方法,synchronized
锁定的是类的Class对象。这意味着,对于非静态方法,每个对象都有自己的锁,而对于静态方法,所有线程共享同一个锁。
1 | public class StaticCounter { |
修饰代码块
synchronized
也可以用于修饰一个代码块,这样可以更精细地控制哪些代码需要同步。在代码块中,需要明确指定一个锁对象。
1 | public class BlockCounter { |
在这个例子中,lock
对象被用作同步锁。increment
和 getCount
方法中的代码块只有在获得 lock
对象的锁时才会执行。这种方式比同步整个方法更加灵活,因为它允许在同步代码块之外执行一些不需要同步的操作。
等待/通知机制
synchronized
关键字还提供了等待(wait
)、通知(notify
)和通知所有(notifyAll
)方法,这些方法用于线程间的协作。
等待/通知机制涉及以下三个方法:
wait()
:当前线程调用wait()
方法后,它会释放对象的监视器锁(即让出锁),然后进入等待队列。线程会一直等待,直到被其他线程通过notify()
或notifyAll()
方法唤醒。notify()
:当一个线程调用notify()
方法时,它会随机选择等待队列中的一个线程,并将其唤醒,使其尝试重新获取对象的监视器锁。如果没有线程在等待,notify()
方法没有任何效果。notifyAll()
:这个方法会唤醒等待队列中的所有线程,让它们尝试重新获取对象的监视器锁。至于哪个线程能抢到锁,这说不准。
这些方法必须在 synchronized
代码块或方法中调用,因为它们需要获取当前线程所持有的监视器锁。以下是等待/通知机制的一个简单示例:
1 | public class Counter { |
在这个例子中,increment
方法增加计数,并通过 notifyAll()
唤醒所有等待的线程。decrement
方法检查计数是否为0,如果是,则调用 wait()
方法等待。当 increment
方法执行并增加计数时,它会唤醒所有等待的 decrement
线程。
使用等待/通知机制时,需要注意以下几点:
- 调用
wait()
、notify()
和notifyAll()
的线程必须持有对象的监视器锁。 wait()
方法会使线程放弃锁并进入等待状态,直到被notify()
或notifyAll()
唤醒。notify()
只会随机唤醒一个等待的线程,而notifyAll()
会唤醒所有等待的线程。- 为了避免虚假唤醒(spurious wakeup),通常在
while
循环中使用wait()
方法,并检查唤醒条件。 InterruptedException
可能会在wait()
、notify()
或notifyAll()
调用中抛出,需要妥善处理这个异常。
等待/通知机制是实现线程间协作的有效方式,特别是在需要等待某些条件成立时。然而,它也需要仔细设计,以避免死锁、活锁和资源饥饿等问题。
不需要同步
JVM规范定义了几种原子操作:
- 基本类型(
long
和double
除外)赋值,例如:int n = m
; - 引用类型赋值,例如:
List<String> list = anotherList
。
long
和double
是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long
和double
的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。
1 | public void set(int m) { |
如果多线程读写的是一个不可变对象,那么无需同步,因为不会修改对象的状态:
1 | class Data { |
注意到set()
方法内部创建了一个不可变List
,这个List
包含的对象也是不可变对象String
,因此,整个List<String>
对象都是不可变的,因此读写均无需同步。
可重入锁
当一个线程获得锁后,如果需要继续调用相关函数,可一直持有锁。
隐患:由于在JVM中具有String常量池缓存的功能,因此相同字面量是同一个锁!!!
死锁
当线程间需要相互等待对方已持有的锁时,就形成死锁,进而产生死循环
一个线程可以获取一个锁后,再继续获取另一个锁。例如:
1 | public void add(int m) { |
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()
和dec()
方法时:
- 线程1:进入
add()
,获得lockA
; - 线程2:进入
dec()
,获得lockB
。
随后:
- 线程1:准备获得
lockB
,失败,等待中; - 线程2:准备获得
lockA
,失败,等待中。
此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。
那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA
,再获取lockB
的顺序。
资源共享
锁获取与volatile读有相同的内存语义。线程间共享变量需要使用volatile
关键字标记,确保每个线程都能读取到更新后的变量值。
volatile本身并不保证原子性!!!
在Java虚拟机中,变量的值保存在主内存中。但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是JVM虚拟机将数据写回时间是不确定的!这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。
因此,volatile
关键字的目的是告诉虚拟机:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
volatile
关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
volatile
是线程同步的轻量级实现,主要作用是使变量在多线程间可见,本身并不处理数据的原子性,而是强制对数据的读写及时影响到主内存里。
volatile
会强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取的变量的值。
volatile
变量自身具有三个语义特性:
- **可见性:**保证了不同线程对这个变量进行操作时的可见性,即变量一旦变更所有线程立即可见
- **有限原子性:**对任意单个volatile变量的简单读写操作具有原子性,复合操作不具有原子性(如i++)
- **重排序禁止:**禁止进行指令重排序
对于volatile
修饰的变量:
- 当写时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存
- 当读时,JMM会把该线程对应的本地内存置为无效,线程会直接从主内存中读取共享变量
- 此时实际上是线程间通过主内存完成了一次消息通信,即线程A向B发送消息
volatile
原理
为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序。为了保证内存可见性,编译器会在生成指令序列的恰当位置插入内存屏障指令来禁止特定类型的处理器重排序。
实现原理是在指令序列执行过程中,通过在volatile
写操作后面插入StoreLoad
屏障(x86平台),仅对volatile
写-读进行重排序(x86会忽略读-读、读-写、写-写的重排序)从而实现正确的内存语义。
从汇编角度来说,操作volatile变量会多出一个lock前缀指令,其相当于内存屏障。执行该屏障开销昂贵,因为处理器通常会把写缓冲区的数据全部刷新到内存中。
为了保证各个CPU的缓存一致性,每个CPU通过嗅探在总线上传播的数据来检查自己的缓存的数据有效性,当发现自己缓存行对应的内存地址的数据被修改,就会将该缓存行设置为无效,当CPU读取该变量,发现所在的缓存行被设置为无效,就会重新从内存中读取数据到缓存中
volatile
使用
- 适用场合:多个线程读,一个线程写的场合
- **使用场景:**通常被 作为标识完成、中断、状态的标记,值变化应具有原子性
- **充分利用其可见性:**即volatile能够保证在读取的那个时刻读到的肯定是最新值
- 重点声明: volatile主要使用的场合是在多线程中可以感知实例变量被变更了,并且可以获得最新的值使用,也就是用多线程读取共享变量时可以获得最新值使用,但不能保证你在使用最新值过程中最新值不发生变化!很可能在使用之后,最新值已经变更。原数据变成过期数据,这时候就会出现数据不一致(非同步)的问题
原子性操作
不进行操作则不保证原子性!!!
- 多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子
- 若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致
- 对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步
针对工作内存和主内存之间的原子性交互,JVM提供了如下原子性指令:
- read: 作用于主内存,将变量的值从主内存传输到工作内存,即数据读取到本地内存
- load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
- use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
- assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
- store: 作用于工作内存,将赋值完毕的工作变量的值传输给主内存
- write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,JVM提供了另外两个原子指令:
- lock: 作用于主内存,将一个变量标记为一个线程独占的状态
- unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
以上指令在使用时必须遵循如下规范:
- read-load、store-write必须搭配使用
- 不允许线程丢弃最近一次assign操作,一旦在工作内存改变后必须同步回主内存
- 不允许线程没执行assign操作就执行主内存同步操作
- 变量必须在主内存生成,执行assign的前提必须是执行了read-load-use
- 一个变量的同一时刻只允许被一个线程执行lock操作,允许lock被同一线程多次执行,但必须执行相同次数的unlock操作
- 执行lock操作必须清空所有工作内存该变量值,在执行引擎使用该变量时必须重新load或assign初始化该值
- 使用unlock必须先执行lock,且执行unlock前必须将此变量同步到主内存
Java函数式编程
一、函数式编程引入
java比较痛苦的一点是要经常新建类,给他起名,写构造函数等等。
匿名类
为了简化这种负担,java有匿名类的机制,可以省略掉这个外部类的定义。匿名类是一种特殊的类,它没有显式的名称,通常用于创建临时的、只需使用一次的类实例。匿名类通常被用于实现接口、抽象类或者作为方法参数。
匿名类的特点包括:
- 没有显式的类名。
- 可以实现接口或继承自类,但不能同时实现多个接口或继承多个类。
- 通常用于简单的、一次性的类实例创建。
- 可以在方法内部、构造函数内部或者其他类的成员内部创建。
函数式编程
有时候多方代码,真正有效的就一行输出语句。创建线程是为了执行某一个任务,任务也就是一个方法,那我们为何不直接传入一个方法呢?干脆把新建类也省去。
这就是函数式编程的一个核心概念,函数式编程的主要抽象是函数,函数是一等公民。
所谓的一等公民,指的是函数与其他数据类型一样,处于同等地位。函数可以在任何地方定义,在函数内或函数外,可以作为函数的参数和返回值,因此可以对函数进行组合。
放在这个代码里理解,就是这短短的一行函数,作了线程创建的参数,用到了Lambda函数。这是函数式编程和我们面向对象的世界中不一样的一种抽象。
命令式编程
下面我们再来看另一组编程范式。
面向对象是一种典型的命令式编程,命令式编程是关于定义如何做。
怎么做,是一种面向硬件的抽象,程序员需要告诉机器每一步的实现过程。命令式程序有变量(对应着存储单元),赋值语句(获取,存储指令),表达式(内存引用和算术运算)和控制语句(跳转指令)。简单来说,命令式程序就是一个冯诺依曼机的指令序列。
声明式编程
声明式编程的风格,是关于定义要做什么而不是如何做,更接近自然语言的接口。
同样的功能,声明式的代码不仅写起来起来更简洁,不需要看函数内部的实现细节就可以了解大意。比如这段这段声明式编程的代码只是说:我要过滤(filter)
一下这些店 构成的流(stream)
, 只把那些销量大于1000
的留下, 计算出个数就行了。
流式编程是一种典型的声明式编程,更接近自然语言接口,这一些函数调用返回一个结果给count
变量,也规避了count
自增前的初始化问题。
这也是函数式编程的另一个优势,不需要考虑过程量的副作用。
看这例子:两段代码都是先排序,然后返回第一项作为最近的店。区别在于上面的排序改变了这个表的顺序,按照距离由小到大排序。而流式编程的sort
并未改变原数据。也就是函数执行没有副作用****。
第一个排序,跟下边的比起来也是唯一一个没有使用Lambda表达式的排序写法,它的compartor接口
就需要在这个店铺的类里边继承接口,然后重写compareto方法
。
在排序的代码中,后面几种出现了 ->
、::
的,都用了lambda表达式实现compartor
这个比较接口。
可以看到都是lambda函数,有的刷灰了意思是还可有更简化的写法。
我们可以对比集合排序的比较器接口的几种使用lambda函数实现的方法,从compartor接口的例子中了解lambda函数的语法。
二、lambda函数
这么多次OO作业肯定大家都用到过,尽管我们在作业中使用Lambda的场景并不是为了函数式编程,是使用新特性来让代码的书写更加优雅。
Lambda表达式语法
这张图使用了刚刚提到过的runnable接口
的实现的例子。包括函数签名,这个标志着lambda函数的箭头,还有函数式编程中最重视的函数实现。
这三部分组成了Lambda表达式的完整结构,这一行就可以作为一个runnable接口
变量。lambda表达式也可以向我们刚刚创建线程那样作为参数进行传递。
函数签名
函数签名包括函数名、函数的返回值和传入参数。
刚刚提到了,lambda函数是匿名类的进一步简化。lambda的匿名性省略了函数名。同时它有一种更强大的省略特性,这种机制被允许的原因是java编译器的自动类型推断。
- 返回值可以省略,可以从接口类型判断出返回值。
- 参数的类型,也可以被省略。
- 如果只有一个参数,可以省略括号。
- 把它存成 比较器接口变量,借助这个变量的
reverse方法
我们可以实现逆序。
函数主体
-
主体只包含一个语句,可以省略大括号。刚刚的实现里,箭头后边都只有一条语句,没有大括号。
-
函数实现也可以语句块作为函数,这里除了比较距离,还比较了年龄这个参数,实现了多重条件的比较。
函数实现比较复杂,下面介绍如何一步步化简省略定义这种多重条件的比较逻辑。
首先
::
这个符号,两个冒号,像C++的作用域解析运算符,是Java 8的一种更方便的方法引用。我们可以通过使用这种实例方法的引用和
Comparator.comparing
方法来避免定义比较逻辑——它会自动提取和创建一个基于这个函数的Comparable
。这里看起来没有lambda表达式的结构,是怎么实现的接口呢?具体原因我们放在后边再展开讲。
Lambda表达式的化简
总结一下刚刚逐步简化的Lambda表达式。它的作用是取代一个类,用来实现接口。
函数式接口
Lambda表达式的语法是什么支持的呢?这种实现的对象类型。再怎么函数式编程,也要在java面向对象的机制中扩展。表达式的类型是函数式接口。
只有一个接口函数需要被实现的接口类型,称为”函数式接口“。为了避免后来的人在这个接口中增加接口函数导致其有多个接口函数需要被实现,变成”非函数接口”,我们可以在这个上面加上一个声明@FunctionalInterface
, 这样就无法在里面添加新的接口函数。
函数式接口的前提条件
-
使用Lambda必须具有接口,且要求接口中有且仅有一个抽象方法。
无论是JDK内置的Runnable、Comparator接口还是自定义的接口,只有当接口中的抽象方法存在且唯一时,才可以使用Lambda。
-
使用Lambda必须具有上下文推断。
也就是方法的参数或局部变量类型必须为Lambda对应的接口类型,才能使用Lambda作为该接口的实例。上文提到的依托java编译器的自动类型推断的省略机制要求上下文推断。
几种常见的函数式接口
predicte
:推断出参数类型返回值类型,是用来判断真假的函数接口。 函数实现是一个布尔表达式。BinaryOperator
:接收两个参数,返回一个值,返回值和参数的数据类型相同。体现一种映射关系,x y ->x+y
。comparing
:上文提到的 “comparing
没有lambda表达式的结构是怎么实现的接口?” 因为这个方法返回了一个函数式接口,使用comparing()
的地方,也就是sort()
的传入参数就是函数式接口function。
comparing()
的传入参数也是函数式接口function。key::lambda
是对函数式接口的更一步简化。
集合的函数式接口
java集合框架也新增部分函数接口用于与Lambda表达式对接。
- 作业中很常见,比如说removeif规避了for遍历删除的问题,这里的参数就是Prediect 函数式接口。
- 创建优先队列的构造参数,是一个比较器接口。
- foreach遍历接受函数式接口做参数。
- 第四单元类重名的异常机制,可以借助这个merge方法,BinaryOperator接口作为重映射函数。
作业中使用lambda函数的优化
unit1
-
如果在某些情况下我们只需要实现一些行为/操作而不需要实现状态,OOP 会限制将该行为包装在一个类中以便能够执行它。会导致不必要的冗长代码,其中计算只需要执行一个函数。
-
合并同类项的加法类:代码完美遵循了面向对象地封装概念。美中不足是略为繁琐
-
合并同类项:选用hashmap的merge()方法和lambda函数来实现合并同类项非常简洁。
- forEach()方法用于对 HashMap 中的每个键值对执行指定的操作。匿名函数 lambda 的表达式 作为 forEach()方法的参数传入。
- merge()方法用于合并两个hashmap,使用lambda表达式 (oldValue, newValue) -> (oldValue + newValue) 作为重映射函数。
- Java 8的方法引用更方便,方法引用由::双冒号操作符标示,使用BigInteger::add作为重映射函数即可。
-
减法:
- 由于hashmap.merge()在插入hashmap2中不存在的key与其对应的value时不会调用重映射函数,故减法不能使用BigInteger::subtract作为映射函数;
- 解决办法为减数先取反,再与被减数调用quanticAdd()即可
-
乘法:
- 将两个BaseKey相乘后的新BaseKey作为merge方法的key参数,系数的乘积作为value参数,重映射函数BigInteger::add
- Key为自定义类型BaseKey,重写hashcode()和equal()后便于合并同类项
- 由于需要维护可变类型BaseKey作为hashmap的key的不可变性,以及value代表的系数为不可变类型BigInteger,没有出现深浅拷贝的Bug
三、Stream流式编程
Stream 使用类似 SQL 语句从数据库查询数据的方式来提供一种对 Java 集合运算和表达的高阶抽象。
Stream API 将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理, 比如筛选,排序,聚合等。
流式编程特点
- Stream并无数据存储,不会修改背后的数据源。所有惰性操作以pipeline的方式执行,减少迭代次数.
- 处理•大量元素时,为了提高性能需要并行处理,并利用多核架构。并行化的Stream 不需要再写多线程代码,所有对它的操作会自动并行进行的。
- 流的操作可以分为两类:处理操作、聚合操作。
- 处理操作(惰性求值):诸如filter、map等处理操作将Stream一层一层的进行抽离,返回一个流给下一层使用。
- 聚合操作(及早求值):从最后一次流中生成一个结果给调用方,得到最终的结果而不是Stream。
Stream API
- filter():过滤大规模数据集合。接受一个前面提到的 Predicate 断言型函数式接口,传入一个lambda表达式作为过滤逻辑,获得一个新的列表。
1 | //Unit4作业 |
- map():是函数式编程中非常重要的一个概念,能够将对象进行转换
1 | // 为每个订单加上12%的税,使用reduce计算总开销 |
ForkJoin框架
四、函数式编程的特点与优势
特点
-
函数是输入和输出之间的映射。可以将其视为将输入转换为输出的“黑匣子”。
(1)函数避免改变状态和改变数据。他们观察到的所有状态只是提供给他们的输入。
(2)函数不会改变输入的值,对它们的执行没有副作用。
(3)对于每个输入,都有相同的输出。
-
以上特点自然适合并发和并行适用性。计算朝着更多内核和分布式/并行计算的方向发展,事实证明函数式编程更适合这些要求。
-
java并不是很典型的函数式编程语言。Apache Spark 是一个用 Scala 编码的大数据平台,它是一种函数式语言。另一个例子是 R,这是数据科学家中最流行的语言,它是函数式的。
优势
单元测试
因为FP中的每个符号都是final的,没有什么函数会有副作用。谁也不能在运行时修改任何东西,也没有函数可以修改在它作用域之外的值给其他函数继续使用(在指令式编程中可以用类成员或是全局变量做到)。这意味着决定函数执行结果的唯一因素就是它的返回值,而影响其返回值的唯一因素就是它的参数。
测试程序中的函数时只需要关注它的参数就可以。完全不需要担心函数调用的顺序,也不用费心设置外部某些状态值。
调试时的可复现性
如果一段FP程序没有按照预期设计那样运行,调试的工作非常容易,因为这些错误是百分之一百可以重现的。FP程序中的错误不依赖于之前运行过的不相关的代码。
而在一个指令式程序中,一个bug可能有时能重现而有些时候又不能。因为这些函数的运行依赖于某些外部状态, 而这些外部状态又需要由某些与这个bug完全不相关的代码通过某个特别的执行流程才能修改。
并发执行
- 所有FP程序都是可以并发执行的。由于根本不需要采用锁机制,因此完全不需要担心死锁或是并发竞争的发生。
- 某个FP程序本身只是单线程的,编译器也可以将其优化成可以在多CPU上运行的并发程序。
- 这在指令式编程中是无法做到的,因为每一个函数都有可能修改其外部状态,然后接下来的函数又可能依赖于这些状态的值。