很尴尬,一直没空整理,好久了才写了一小部分。
分为八个部分,分别是多线程起步、线程间通讯、进程间通信、Synchronized和CAS、JUC和AQS,线程池、原子类、其他。
为啥要用多线程
我认为这是异步并行的思想,单一个处理器的性能有限,如果只在一个维度进行物料的堆积难免会有局限性会碰到难以逾越的界限。多核处理器的意义实际是维度的扩展,只不过没有体现在立体的形式上,如果未来发展是可以期望看到刀剑神域里的晶体核心的。
当硬件在维度上扩张后,经常会出现一核干活,七核围观的情况,导致购买硬件的用户只买了一份优越感。后来越来越多的软件进行了优化,比如苹果笔记本自带的视频剪辑软件由于出色的多核优化比window系统其它同类软件要优秀的多。后来3A游戏也做了很大的多核优化,比如荒野大镖客2优化支持六核CPU。
当然多线程也有着很多问题,频繁的上下文切换相对于CPU高速的计算能力无疑会损耗很多性能,死锁问题,竞态问题等等。为了解决这些问题,有很多大牛提出了很多方案,比如偏向锁,分段锁,协程等等
而目前出现线程不安全的问题大多是在于主内存和工作内存数据不一致和指令重排序,因此要考虑线程间如何通信,线程之间如何同步。
1、多线程起步
1.1 进程和线程
这段代码能很直观的表现出二者差距:
private static class t1 extends Thread{
@Override
public void run() {
for (int i=0;i<3;i++){
try {
TimeUnit.MICROSECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("thread1");
}
}
}
public static void main(String[] args) {
new t1().run();
new t1().start();
for (int i=0;i<=3;i++){
try {
TimeUnit.MICROSECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("thread2");
}
}
所以QQ是一个进程,你可以ctrl+shift+esc查看这个进程,但当你聊天的时候即时通讯是一个线程或多个线程协调工作。
进程是资源分配的最小单位,线程是CPU调度的最小单位
线程和进程是和操作系统息息相关的,java并没有资格和计算机硬件打招呼,所有的操作都是通过操作系统转达的。
所以要理清进程和线程的关系,需要理解并掌握计算机系统和计算机组成原理。这里涉及到了中央处理器、寄存器和线程栈等等。
笼统来说,计算机后宫养着很多进程,但是CPU某时只能临幸一个进程,你所看到的多个进程雨露均沾是因为CPU分时间片在不同的进程之间来回跑,至于怎么跑由操作系统的调度算法来翻牌决定。每个进程运行时都需要调动就绪相应的资源因为每个进程的喜好都不一样。而线程属于这个进程并被其约束,可以享用进程的东西。进程之间通信和线程间通信也很大不同。
举个广为人知的例子:
计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行。
假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个CPU一次只能运行一个任务。
进程就好比工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态。
一个车间里,可以有很多工人。他们协同完成一个任务。
线程就好比车间里的工人。一个进程可以包括多个线程。
车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存。
可是,每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。
一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域。
还有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。
这时的解决方法,就是在门口挂n把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做"信号量"(Semaphore),用来保证多个线程不会互相冲突。
不难看出,mutex是semaphore的一种特殊情况(n=1时)。也就是说,完全可以用后者替代前者。但是,因为mutex较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种设计。
1.2 开启多线程的五种方法
1、继承Thread
Thread myThread = new Thread(()-> System.out.println("hello thread"));
myThread.start();
2、实现Runnabele
Runnable runnable = new Thread(() -> System.out.println("hello runnable"))
((Thread) runnable).start();
3、实现Callable接口
FutureTask<String> futureTask1 = new FutureTask<>(()-> "hello callable");
new Thread(futureTask1,"B").start();
System.out.println(futureTask1.get());
4、线程池实现
ExecutorService es = Executors.newCachedThreadPool();
es.execute(new Thread(()-> System.out.println("hello executor")));
前两种是最熟悉的两种方法。
第三种方法的Callable接口是这么回事:
class Allable implements Callable<String>{
@Override
public String call() throws Exception {
return "hello callable";
}
}
FutureTask futureTask = new FutureTask<String>(new Allable());
new Thread(futureTask,"A").start();
对比分析,第三种方式不是重写run方法而是call方法,call方法有返回值,call方法要抛异常,callable是一个泛型接口。
引入callable接口的目的是方便获取线程执行后返回值。那么如果我们来实现类似的功能需要怎么做?
首先是Future接口,call()方法的返回值需要存储在主线程的对象中,这样线程就知道该结果是多少。关于Future,其核心思想是如果一个方法(一般就是放在call方法中)十分耗时,那么我们就没有必要一直等待其结果返回,而是可以在调用该方法的时候直接返回一个Future,我们可以通过这个Future去控制该方法的过程。
实现Future接口需要重写五个方法,其中最重要的三个是:
public boolean cancel(boolean mayInterrupt):用于停止任务。如果尚未启动,它将停止任务。如果已启动,则仅在mayInterrupt为true时才会中断任务。
public Object get()抛出InterruptedException,ExecutionException:用于获取任务的结果。如果任务完成,它将立即返回结果,否则将阻塞,然后返回结果。
public boolean isDone():如果任务完成,则返回true,否则返回false
这样我们启动线程需要Runnable,传递异步计算的结果需要Future。

很明显两个我全都要,恰好有一个类既实现了Runnable接口也唯一实现了Future接口,那就是FutureTask,我们可以把Callable当作参数传入到Future中,然后把这个FutureTask对象当作一个Runnable线程。
主要清楚我们不能用Thread去实现callable接口,因为callable接口有返回值,而Thread没有返回值。
第五种为定时器:
Timer timer = new Timer();
timer.scheduleAtFixedRate((new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}), 2000, 1000);
不常见不讨论。
所以本质上也就这么三种开启线程的方法。
1.3 线程的状态

大概就是六种状态:
1、线程初始状态,还未start().
2、yield()或start()后,join()结束,sleep()结束,或者拿到锁后线程进入就绪状态,等待系统调度算法分配CPU时间片后进入运行状态。
3、被调度算法选中线程进入运行状态。
4、阻塞状态,没有获取到锁进入阻塞,抢到锁后进入就绪队列。
5、等待状态,除非被显式的唤醒则一直处于等待状态,定时等待则不会一直等待,在一定时间后会自动苏醒。
6、终止状态,线程一旦终止则永远无法复生。终止的线程调用start()则会报java.lang.IllegalThreadStateException。
关于锁的问题:
- Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入TIMED_WAITING状态,但不释放对象锁,millis后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
- Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与sleep()类似,只是不能由用户指定暂停多长时间。
- t.join()/t.join(long millis),当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程会释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程进入就绪状态。
- obj.wait(),当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。
- obj.notify()唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。
关于线程的状态可以调用t.getState()方法来获取。
2、线程间通信JMM和指令重排
2.1JMM
要区别进程间通信:管道(pipe)、命名管道(FIFO)、消息队列(MessageQueue)、共享存储(SharedMemory)、信号量(Semaphore)、套接字(Socket)、信号 (sinal)。
线程间通信主要为共享内存和消息队列。而JAVA内存模型JMM是共享内存。

图中可知,java程序的实例域,静态域和数组元素都是放在堆内存的,堆内存是线程共享的,所以也是并发车祸高发地段。而局部变量,方法定义的参数和异常处理器参数不参与线程共享。
关于JMM原理看图就很容易理解:

每次现场对数据操作都需要把主内存的变量拷贝一份到自己的工作内存去操作,操作完后再把操作过的工作内存的变量同步到主内存中。很明显线程A在自己本地内存做的动作线程B并不知道,如果拷贝的是同一个主内存的共享变量就会产生冲突。如果读写冲入则会脏读。而使用volatile会强制同步主存,保证可见性。
为什么非要拷贝一份变量到本地内存,直接操作主内存的变量不就没这档子事了吗?
关于这个问题我也找到了答案:

CPU的速度太快了,没有内存的读写跟得上,而CPU内部一般采用三级缓存架构,一级缓存的速度是二级的成百上千倍,同样二级缓存的读写速度是三级缓存的成百上千倍并且到了主内存就彻底变了,三级缓存读写速度是主内存的百万千万倍。所以线程要拷贝一份资源到离上帝最近的地方,不然太多来不及。
2.2指令重排
单个线程中当两行代码没有父子关系等关联时,CPU会进行指令重排序,为的是提高并行度。可以代码验证CPU存在指令重排:
public class Test {
static int a, b, x, y;
public static void main(String[] args){
long time = 0;
while (true) {
time++;
a = 0;b = 0;x = 0;y = 0;
Thread thread1 = new Thread(() -> {
a = 1;
x = b;
});
Thread thread2 = new Thread(() -> {
b = 1;
y = a;
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (Exception e) {}
if (x == 0 && y == 0) {
break;
}
}
System.out.println("time=" + time + ",x=" + x + ",y=" + y);
}
}
程序中开启了俩线程,线程1执行a=1,x=b,线程2执行b=1,y=a。
a=1和x=b是不相干的两行代码,因此CPU可以对这两个指令进行重排序。同理,b=1和y=a也可以指令重排序。
假如CPU完全按代码顺序执行,那么可能出现这么几种情况:x=0,y=1,x=1,y=0,x=1,y=1。不可能出现x=0,y=0。
程序会不停的循环执行,当x=0,y=0的时候退出循环。很明显出现了指令重排的现象。
单例模式的DCL(double check lock)必须要设置INSTANCE为Volatile,否则可能导致线程取到的实例未完全初始化。
as-if-serial语义:
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
保证有依赖关系的操作不能指令重排。as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
从JAVA源代码到最终实际执行的指令,会分别经历下面三种重排序,
源代码 -->1.编译器,优化重排序 -->2.指令级,并行重排序 -->3.内存系统重排序–>最终执行的指令序列
到指令级别的时候,会发现并不是等上一条指令跑完才跑吓一条,而是前一个刚起步下一个就准备出发,是一条流水线似的执行过程。
happens-before定义:
JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
其对程序员承诺:如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
其对编译器和处理器承诺:操作之间存着happens-before关系,并不要求执行的顺序性,只要结果的最终一致性不出错。
总的来说:as-if-serial语义保证单线程内的程序的执行结果不被改变,happens-before关系保证同步的多线程程序的执行结果一致性。
memory barrier内存屏障:

保证数据可见性和防止指令重排,有下面三种具体指令。
store barrier
对应sfence指令
- 保证了sfence前后store指令的顺序,防止重排序。
- 通过刷新store buffer保证了sfence之后的store指令全局可见之前,sfence之前的store要指令要先全局可见。
load barrier
对应lfence指令,
- 保证了lfence前后的load指令的顺序,防止重排序。
- 刷新load buffer。
full barrier
对应mfence指令
- 保证了mfence前后的store和load指令的顺序,防止重排序。
- 保证了mfence之后的store指令全局可见之前,mfence之前的store指令要先全局可见。
Store:将处理器缓存的数据刷新到内存中。
Load:将内存存储的数据拷贝到处理器的缓存中。

在系统底层通过MESI保证数据一致性,如果不能保证则锁总线。系统底层通过内存屏障sfence,mfence,lfence等系统原语保证有序性,不行的话就锁总线。
再深入就是MESI和x86架构了。我觉得没必要了。
3、Synchronized和CAS
3.1从机器语言到汇编语音再到代码实现
为了清楚理解Synchronized关键字的本质。先引入JOL(Java Object Layout)工具查看对象是如何存储的。
<dependencies>
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
</dependencies>
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 49 ce 00 20 (01001001 11001110 00000000 00100000) (536923721)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
可以看出Object o = new Object()占了16B的存储空间。学过C++的更容易分析一个对象占了多少空间。下图是对象的存储格式:

32位下每个格子4B。64位的就是8字节一格子,指针压缩的话是4字节,实例数据的大小根据类型长度来定。对齐是为了保证8的倍数,因为内存页一次读取最小单位是8B的倍数也就是Cacheline的概念。


多少位不重要,主要关注最后的三位标志位。这三位标识了Synchronized锁的本质。就是机器语言的后三位001表示无锁,101表示偏向锁。这里面有锁升级的潜规则。不过先从实现来说。
从代码层面来说,Synchronized可以用在方法上也可以用在代码块中
其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种。如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。

由此引出过8锁的问题,只要注意锁的是同一个对象,只是时间上有执行顺序,锁的是不同对象则互不干扰。在 java 内部,同一线程在调用自己类中其他 synchronized 方法/块或调用父类的 synchronized 方法/块都不会阻碍该线程的执行。就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。因为java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的。
从汇编层面来理解:
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
method();
}
}
private static void method() {
}
}
javap -v SynchronizedDemo.class查看字节码文件:

执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。可以看到有两个monitorenter指令因为synchronized关键字会保证线程无论以哪种方式退出都会释放获取到的锁,所以说两个monitorexit一个是正常执行退出释放锁,另一个是发生异常退出释放锁。在method方法上加synchronized的时候,当前修饰的方法的字节码指令中并不会有monitor相关指令,只有flag标志位出现ACC_SYNCHRONIZED。

想要获取monitor的线程,首先会进入_EntryList队列。
当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。。
再深入就是lock comxchg指令。
3.2 Synchronized锁升级
从上面的图可以了解到有四种状态:无锁 - 偏向锁 -轻量级锁(自旋锁)-重量级锁
偏向锁 - markword 上记录当前线程指针,下次同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,所以,偏向锁,偏向加锁的第一个线程 。hashCode备份在线程栈上 线程销毁,锁降级为无锁
有争用 - 锁升级为轻量级锁 - 每个线程有自己的LockRecord在自己的线程栈上,用CAS去争用markword的LR的指针,指针指向哪个线程的LR,哪个线程就拥有锁
自旋超过10次,升级为重量级锁 - 如果太多线程自旋 CPU消耗过大,不如升级为重量级锁,进入等待队列(不消耗CPU)-XX:PreBlockSpin
自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
偏向锁由于有锁撤销的过程revoke,会消耗系统资源,所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。

要注意的是:
当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;
当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值。或者简单说就是重量锁可以存下identity hash code。
申请重量级锁以及阻塞队列的唤醒都需要操作系统从用户态切换为内核态非常消耗系统资源。
3.3 CAS(compare and swap)
无论是JUC还是其底层AQS或者是Atomic包都离不开CAS。CAS又叫乐观锁,也是一种无锁机制。
CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机可以使用处理器提供的lock cmpxchg指令实现。
同样CAS会有各种问题比如:
ABA问题,就是A改完数值后B又把数值改为原来的数值,这样CAS无法知道这个期望值其时不知原来的期望值。简单来说,你复合的女友你以为是期望值,其时她和老李同居了一年之始你不知道而已。解决很简单打个版本号。当然基本类型无所谓,这里是指引用类型的ABA。
CAS是非阻塞同步,就是不会进入阻塞队列,会一直在等待队列自旋,这对于性能是个很大的消耗。

当对一个共享变量执行操作时CAS能保证其原子性,有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。
三种锁特点:

4、JUC和AQS
Q.E.D.






Comments | 0 条评论