前言
建议先看下java系并发模型的发展
2018.03.15补充
线程安全,是并发访问(也就是读写)资源导致的,分为读读、读写、写写三种情况,有以下几个点:
- 读读无所谓,写写开发者一般有明显的意识要加锁,通常出问题在读写。
- 读写通常从代码上看不出来,比如i++,jvm将其编译为8个指令,读取、加1、赋值。
- 对于long型数据,jvm约定64位值的赋值操作需要两次32位赋值。
- 所以,两个线程对一个变量并发执行i++,都有上万中执行路径
- 以上只针对最简单的cpu ==> 内存硬件架构,考虑到cpu 通常还会有多级缓存,读写的问题就更严重了。
所以,并发问题,具体的说就是安全的并发读写。
- 跟内存模型有关系,比如cpu是否带有缓存
- 跟编译系统有关系,编译器将一条代码翻译成多少条指令,是否夹杂了指令重排序
- 跟指令系统有关系,os或vm 将哪些操作作为原子操作
数据库系统中,为了描述并发读写的安全程度,还提出了隔离性的概念。
所以,不准确的说,并发读写是线程安全的核心问题。也正是如此,cas (部分)解决了读写问题,java.util.concurrent
以CAS为基石(实际上线程并没有停止运行),不使用锁(意味着内核陷入,和竞争时的上下文切换),就可以解决大部分并发问题。反过来也说明,并发读写问题是线程安全的核心问题。
2018.10.22 补充:关于线程、并发在硬件、os、jvm等的演变,参见AQS1——并发相关的硬件与内核支持
锁的粒度
public class IntegerIterator implements Iterator<Integer>{
private Integer nextValue = 0;
public synchronized boolean hasNext(){
return nextValue < 10000;
}
public synchronized Integer next(){
if(nextValue = 10000)
throw new IteratorPastEndException();
return nextValue++
}
public synchronized Integer getNextValue(){
return nextValue;
}
}
操作系统中的线程
单线程
我们如何影响线程的行为呢?
- 创建它:继承Thread,实现Runnable,实现TimerTask(现在不推荐)
- 启动它:start
- 暂停它(唤醒它):sleep(自动唤醒),wait和notify
-
停止它(取消它):
a. interrupt,注意这种停止并不是抢占式的,代码中要遵守一定的约定。
b. 设置一个变量(显式的interrupt)
class thread{ public boolean isRun = "true"; void run(){ while(isRun){ xx } } void stop(){ isRun = false; } }
多线程
多个线程同时执行时,有以下几种可能:
-
乱序执行
-
协作执行(部分有序)
-
某一部分不能被中断
-
部分有序的
-
-
有序执行(执行完一个,再执行另一个)
比如java的join方法
So,从乱序、部分有序、到有序执行,这是一个渐近的过程。这也从另一个侧面证明,java的多线程程序,本质上就是在线性程序上加了一些限定。
从另一个角度讲,找出串行化代码的可并行部分,并将其分解到各个线程中,本身就是一种降低程序复杂度的方式。考虑到多线程还有线程切换和同步的开销,串行代码并行化,并不是越多越好。
这比起现在新兴的、原生支持多核和并行化的编程语言(比如Go语言),逊色不少。
那么在串行代码并行化的过程中,带来了一些问题(集中在线程交叉的部位)。因为涉及到java内存模型等方面,这个复杂性并不比当年单道OS改成支持多道任务的OS时低。
- 原子性,有些操作一旦开始执行就不能被打断,在多线程领域则是某个时刻只能有一个线程进入临界区。
- 可见性,一个线程对变量或对象的改变必须及时让其它线程知道。而每个线程都有自己的缓存,不加处理的话就会破坏这一点。
- 有序性。某些操作必须是有序的。比如只有当jvm初始化对象的线程将对象初始化完毕了,才允许其它线程访问该对象。而java代码编译和执行时会进行指令重排序,一个方法内后面的代码有可能跑到前面执行,cpu也可能在还未执行完一个方法时,执行下一个方法的指令(因为cpu只看到一个指令序列,而不是人类看到的代码执行和方法跳转)。
C语言中的多线程
看看C语言下写多线程程序什么感觉
#include <stddef.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h> //用到了pthread库
#include <string.h>
void print_msg(char *ptr);
int main(){
pthread_t thread1, thread2;
int i,j;
char *msg1="do sth1\n";
char *msg2="do sth2\n";
pthread_create(&thread1,NULL, (void *)(&print_msg), (void *)msg1);
pthread_create(&thread2,NULL, (void *)(&print_msg), (void *)msg2);
sleep(1);
return 0;
}
void print_msg(char *ptr){
int retval;
int id=pthread_self();
printf("Thread ID: %x\n",id);
printf("%s",ptr);
// 一个运行中的线程可以调用 pthread_exit 退出线程, 参数表示线程的返回值
pthread_exit(&retval);
}
pthread_create 四个参数
- 线程对象
- 线程的属性,比如线程栈大小
- 线程运行函数
- 线程运行函数的参数
从c语言中线程的代码实例和操作系统的基本原理(进程通常是执行一个命令,或者是fork),我们可以看到,线程可以简单的认为是在并发执行一个函数(pthread_create类似于go 代码中常见的go function(){xxx}
)。
java提供的多线程
-
从代码的感觉上讲,我经常很困惑,比如
class ThreadA extends Thread{ public void run(){ codeA; threadb.join(); //threadB.join()的意思是向threadB发送jion消息,加入到当前线程中来,threadB完事执行codeB; codeB; } } class MyThread extends Thread{ public void run(){ synchronized(b){ xx b.wait(); //此处是将当前线程挂起 } } }
我的直观感觉是:挂起当前线程,应该是当前线程thread应提供一个方法wait,然后thread.wait()。而事实是通过锁对象的wait方法做到的,也就是通过非线程对象的操作改变了线程对象的状态,我在ThreadLocal小结中也提到了类似的情形。原因便是:任何一个方法在执行时都可以通过Thread.currentThread()获取当前线程对象,从而通过线程对象对线程进行一定操作。如果这种感觉不爽,可以显式的使用Condition的await的和signal方法。
java线程池
笔者刚实习的时候,创建一个线程用的是new thread(xx).start()
。那么从代码结构的设计等角度,应该减少这类使用,更多应该考虑
- 通过线程池控制代码的并发量
- 如何优雅的与spring等框架结合,比如使用quartz(这样还可以更好的控制线程的生命周期,以及事件通知等)
对于一个简单的java线程池
- 线程池初始化时,即启动其管理的所有线程
-
线程池中的线程运行逻辑:
- 从任务队列中取任务(取不到任务会阻塞)
- 执行任务
- 转至步骤1
注意:
-
线程池中的线程跟线程池要执行的任务(我们习惯为让任务成为一个Runnable对象,但实际上这个任务是什么类型都可以)运行逻辑不一样。
-
线程池中的线程所执行的任务,不是线程池赋予的,而是线程自己“取”的
线程创建的成本
2018.7.7 补充:线程池的原理 我们首先来看,为什么说每次处理任务的时候再创建并销毁线程效率不高?
Thread t = new Thread(); // 此时只是在java 层面创建了一个对象
t.start()
native 的start 指令做了很多事情
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
JVMWrapper("JVM_StartThread");
JavaThread *native_thread = NULL;
{
MutexLocker mu(Threads_lock);
if (java_lang_Thread::is_stillborn(JNIHandles::resolve_non_null(jthread)) ||
java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
throw_illegal_thread_state = true;
} else {
jlong size = java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
size_t sz = size > 0 ? (size_t) size : 0;
native_thread = new JavaThread(&thread_entry, sz);
if (native_thread->osthread() != NULL) {
// Note: the current thread is not being used within "prepare".
native_thread->prepare(jthread);
}
}
}
Thread::start(native_thread);
JVM_END
这段代码我也不懂,只是想表明, native 做了很多事情。包括但不限于:
- 创建一个native 线程
-
分配线程栈。jvm 参数
-Xss
,每个线程的堆栈大小,JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.根据应用的线程所需内存大小进行调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右.小的应用,如果栈不是很深,128k应该是够用的,大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。从这里可以看到两点:- 如果xss不显式设置, 新建线程时 os分配1m的空间绝对不是一个很easy的操作
- 线程数量 不准确的说 是一个内存耗费问题,在这个角度看,空间和算力有了一个对应关系。
- 将java 线程 关联到 native 线程上
从中可以看到:尽管java 线程和 os 线程具备一对一关系,但java 仍在jvm 层面上 为线程 维持了一些 数据结构。就好像 线程池中的线程 不是单纯的 new Thread
,java 线程 也不是 单纯的 os 线程。
如果没有这些微观细节,人就很难直观上 感受 线程池的好处。 线程切换的成本
2019.5.27补充:Linux内核基础知识
Java/Jvm 内部工作线程
2018.7.2 补充
java 内部工作线程介绍哪怕仅仅 简单的跑一个hello world ,java 进程也会创建如下线程
"Low Memory Detector"
"CompilerThread0"
"Signal Dispatcher"
"Finalizer"
"Reference Handler"
"main"
"VM Thread"
"VM Periodic Task Thread"
笔者有一次,试验一个小程序,main 函数运行完毕后,idea 显示 java 进程并没有退出,笔者还以为是出了什么bug。thread dump之后,发现一个thread pool线程在waiting,才反应过来是因为thread pool 没有shutdown。进而Java中的main线程是不是最后一个退出的线程
- JVM会在所有的非守护线程(用户线程)执行完毕后退出;
- main线程是用户线程;
- 仅有main线程一个用户线程执行完毕,不能决定JVM是否退出,也即是说main线程并不一定是最后一个退出的线程。
这也是为什么thread pool 若没有shutdown,则java 进程不会退出的原因。
java并发小结
java并发的发展历程
-
使用原始的synchronized关键字,wait和notify等方法,实现锁和同步。
-
jkd1.5和jdk1.6提供了concurrent包,包含Executor,高效和并发的数据容器,原子变量和多种锁。更多的封装减少了程序员自己动手写并发程序的场景,并提供lock和Condition对象的来替换替换内置锁和内置队列。
-
jdk1.7提供ForkJoinTask支持,还未详细了解,估计类似于MapReduce,其本身就是立足于编写可并行执行程序的。
通过阅读《java并发编程实战》全书的脉络如下
- 什么是线程安全,什么导致了线程不安全?
- 如何并行程序串行化,常见的并行化程序结构是什么?Executor,生产者消费者模式
- 如何构造一个线程安全的类(提高竞争效率),如何构造一个依赖状态的类(提高同步效率)?提高性能的手段有哪些? 使用现有工具类 or 扩充已有父类?
性能优化的基本点就是:减少上下文切换和线程调度(挂起与唤醒)操作。从慢到快的性能对比:
- synchronized操作内置锁,wait和notify操作内置队列。考虑到现在JVM对其实现进行了很大的优化,其实性能也还好。
- AQS及AQS包装类
- Lock和Condition(如果业务需要多个等待线程队列的话)
从上到下,jvm为我们做的越少,灵活性越高,更多的问题要调用者自己写在代码里(执行代码当然比劳烦jvm和os效率高很多),使用的复杂性越高。
基于共享内存的数据通信问题
线程安全/对数据进行保护/共享变量副本一致性
锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。因为缓存(cpu缓存、基于java机制的线程工作内存)等原因,可见性并不是天然保证的。
线程的运行需要一定的资源,硬件如打印机、磁盘、数据库和显示屏等,软件如变量等数据结构(其实就是某个特定的内存资源),因为大家都在使用,所以并不能确保资源“申请即得到(这里描述为“可以访问”)”,“得到即可用(这里描述为可以使用)”。硬件资源的分配,由操作系统提供。而软件资源的分配与协调则由开发人员通过代码主动控制。
基于内存的共享变量 + CPU 缓存结构 ==> 共享变量存在多个副本/”cpu本地变量” ==> 语言层级的副本一致性问题 ==> CAP 问题 比如
- 可见性 volatile 其实就类似主备同步中的副本一致性:只有数据同步到备机了,才算对数据操作成功。
- 有序性,其实就是CAP 的操作顺序一致性。编译/运行时重排序 和 网络通信中的“后发先至”异曲同工。
从某种视角来看,共享内存模型下的并发问题都是“共享内存,但不共享寄存器/L1/L2缓存”导致的,线程安全性是某种意义上的副本一致性
几点感觉:
- 读的一定要是(自己或别的线程)最近写入的数据
- 线程A写入变量时,它还是线程A读取时的值
条件变量
假如我们没有“条件变量”这个概念,如果一个线程要等待某个“自定义的条件”满足而继续执行,而这个条件只能由另一个线程来满足,比如 T1不断给一个全局变量 x +1, T2检测到x 大于100时,将x 置0,如果我们没有条件变量,则只通过互斥锁则可以有如下实现:
//thread 1:
while(true){
pthread_mutex_lock(&mutex);
iCount++;
pthread_mutex_unlock(&mutex);
}
//thread 2:
while(true){
pthread_mutex_lock(&mutex);
if(iCount >= 100){
iCount = 0;
}
pthread_mutex_unlock(&mutex);
}
这种实现下,就算 lock 空闲,thread2需要不断重复<加锁,判断,解锁>这个流程,会给系统带来不必要的开销。有没有一种办法让 thread2先被 block,等条件满足的时候再唤醒 thread2?这就要用到条件变量了:
//thread1 :
while(true){
pthread_mutex_lock(&mutex);
iCount++;
pthread_mutex_unlock(&mutex);
pthread_mutex_lock(&mutex);
if(iCount >= 100){
pthread_cond_signal(&cond);
}
pthread_mutex_unlock(&mutex);
}
//thread2:
while(1){
pthread_mutex_lock(&mutex);
while(iCount < 100){
pthread_cond_wait(&cond, &mutex);
}
printf("iCount >= 100\r\n");
iCount = 0;
pthread_mutex_unlock(&mutex);
}
锁与同步,本质上都是通过程序,人为的改变线程的状态(由运行改为挂起),都是通过线程的挂起和恢复机制。
- 从表象上看,锁的lock和unlock在一个代码块中,而同步的wait和notify在不同的代码块。锁,侧重于两个线程的竞争。同步,侧重于两个线程的协作
- 锁只是负责资源的独占访问,但该资源是否语义上可用,并不保证。比如现在没有其他线程访问资源池,消费者线程可以访问资源池,但资源池没有资源时,消费者线程也是需要等待的。因而锁的应用场景比较通用和固定,程序语言可以方便的进行抽象,比如java的synchronized。
- 线程同步,确保了线程在语义上可以使用共享资源。当线程访问共享资源,检测语义不满足(标志位被占用),会被挂起,需要协作线程满足语义后,触发当前线程的继续执行。但因为应用场景多种多样,所以由开发人员手动写入线程挂起和恢复代码。
- 但线程同步时判断资源在语义上是否可用之前,必须先锁定(保护)描述语义的变量值。管理状态依赖性的机制必须与确保状态一致性的机制(锁)关联起来(来自《Java并发编程实战》)