Java教程

掌握Android和Java线程原理上,阿里架构师深入讲解Android开发

本文主要是介绍掌握Android和Java线程原理上,阿里架构师深入讲解Android开发,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

child_thread = nullptr;

// TODO: remove from thread group?

env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer, 0);

{

std::string msg(child_jni_env_ext.get() == nullptr ?

StringPrintf(“Could not allocate JNI Env: %s”, error_msg.c_str()) :

StringPrintf(“pthread_create (%s stack) failed: %s”,

PrettySize(stack_size).c_str(), strerror(pthread_create_result)));

ScopedObjectAccess soa(env);

soa.Self()->ThrowOutOfMemoryError(msg.c_str());

}

}

CreateNativeThread函数通过调用pthread_create函数来创建线程。pthread_create的实现在Bionic目录的pthread_create对象中。Bionic是Android平台为了使用C/C++进行原生应用程序开发所有提供的POSIX标准C库,如果Bionic也有实现pthread_create兼容Android的线程创建。

/bionic/libc/bionic/pthread_create.cpp

int pthread_create(pthread_t* thread_out, pthread_attr_t const* attr,

void* (start_routine)(void), void* arg) {

ErrnoRestorer errno_restorer;

// Inform the rest of the C library that at least one thread was created.

__isthreaded = 1;

pthread_attr_t thread_attr;

if (attr == NULL) {

pthread_attr_init(&thread_attr);

} else {

thread_attr = *attr;

attr = NULL; // Prevent misuse below.

}

pthread_internal_t* thread = NULL;

void* child_stack = NULL;

int result = __allocate_thread(&thread_attr, &thread, &child_stack);

if (result != 0) {

return result;

}

// Create a lock for the thread to wait on once it starts so we can keep

// it from doing anything until after we notify the debugger about it

//

// This also provides the memory barrier we need to ensure that all

// memory accesses previously performed by this thread are visible to

// the new thread.

thread->startup_handshake_lock.init(false);

thread->startup_handshake_lock.lock();

thread->start_routine = start_routine;

thread->start_routine_arg = arg;

thread->set_cached_pid(getpid());

int flags = CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM |

CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID;

void* tls = reinterpret_cast<void*>(thread->tls);

#if defined(i386)

// On x86 (but not x86-64), CLONE_SETTLS takes a pointer to a struct user_desc rather than

// a pointer to the TLS itself.

user_desc tls_descriptor;

__init_user_desc(&tls_descriptor, false, tls);

tls = &tls_descriptor;

#endif

int rc = clone(__pthread_start, child_stack, flags, thread, &(thread->tid), tls, &(thread->tid));

if (rc == -1) {

int clone_errno = errno;

// We don’t have to unlock the mutex at all because clone(2) failed so there’s no child waiting to

// be unblocked, but we’re about to unmap the memory the mutex is stored in, so this serves as a

// reminder that you can’t rewrite this function to use a ScopedPthreadMutexLocker.

thread->startup_handshake_lock.unlock();

if (thread->mmap_size != 0) {

munmap(thread->attr.stack_base, thread->mmap_size);

}

__libc_format_log(ANDROID_LOG_WARN, “libc”, “pthread_create failed: clone failed: %s”, strerror(errno));

return clone_errno;

}

int init_errno = __init_thread(thread);

if (init_errno != 0) {

// Mark the thread detached and replace its start_routine with a no-op.

// Letting the thread run is the easiest way to clean up its resources.

atomic_store(&thread->join_state, THREAD_DETACHED);

__pthread_internal_add(thread);

thread->start_routine = __do_nothing;

thread->startup_handshake_lock.unlock();

return init_errno;

}

// Publish the pthread_t and unlock the mutex to let the new thread start running.

*thread_out = __pthread_internal_add(thread);

thread->startup_handshake_lock.unlock();

return 0;

}

可以看到pthread_create是通过clone的系统调用来创建线程的。

线程安全

==================================================================

知道了什么是线程,也知道了线程是如何产生的,接下来了解一下线程在使用过程中遇到的安全问题,以及如何解决线程安全问题。

导致线程安全的原因


在使用多线程时,不可避免的会遇到线程安全问题,为什么会有线程安全问题呢?主要是这三个原因导致的。

  1. 原子性

  2. 可见性

  3. 顺序性

原子性

前面说过,一个线程对应了一个轻量级进程。而进程之前的切换,是内核采完全公平算法进行切换的,我们无法控制这个过程。所以一个正在运行的线程,可能随时被内核切换,变成非运行状态,一旦我们正在运行的线程发生了切换,就会导致当前线程的执行过程发生中断,这种情况下就会产生线程安全问题。

看一下这样一个简单的函数

int count;

void methodA(){

count = 0;

count = count + 1;

assert count > 1:“产生了线程安全问题”;

}

这个简单的函数在多线程并发的情况下,count就会产生线程安全问题。它再多线程中运行的流程可能如下。

如果函数按照上图的流程执行,就会出现count的值为2的情况,和我们预期的值不一样,导致程序崩溃。

可见性

想要了解什么是可见性,我们先要了解一点计算机硬件的架构知识。硬件层面将存储体系划分为外存、主存、高速缓存和寄存器,后三者又称为内存。为什么要这样设计呢?因为CPU的运行速度是远高于内存的读写速度的,如果CPU直接读写内存,就会出现CPU要浪费很多时间在等待内存的读写上,这一点和前面提到的I/O密集型场景会让CPU造成浪费是一样的道理。为了解决这个问题,于是设计了寄存器,高速缓存和主存三种内存存储器,寄存器是CPU的工作内存,它的读写速度如果是火箭,高速缓存的读写速度就是汽车,而内存的读写速度就是人走路的速度。

再来说一说高速缓存,它也被称为Cache,是位于CPU与主内存间的一种容量较小但速度很高的存储器,一般分为L1,L2,L3三级缓存,在运行速度方面,L1最快、L2次快、L3最慢;容量大小方面,L1最小、L2较大、L3最大,如骁龙865的L2就是512KB,L3是4MB。

CPU在读数据会先在最快的寄存器寻找需要的数据,找不到再去L1,L2,L3最后到L4一次寻找。写数据时,也会先将数据写道寄存器,然后再同步到L1,L2或者L3中的Cache,最后再同步到内存中。在这种架构下,多核CPU就会产生数据不一致的情况。

为什么会产生不一致的情况呢?以上面双核的架构运行这个函数为例。

int a = 0;

void methodA(){

a = a+1 ;

printf(a);

}

这个函数被CPU0中的线程A和CPU1中的线程B同时执行,将两个线程都执行完毕后,a的值应该是2,因为被加1了两次,但是在这种存储架构下,就会出现a的值为1的情况。

会出现这个问题的原因就是可见性,CPU0对a的操作,不能让其他的线程立刻可见,所以就出现了线程的安全性问题。

顺序性

为了性能优化,虚拟机和CPU都会对指令重排序,这样就会导致我们的程序执行的顺序实际编码的顺序不一致。

这里举一个我们用的非常频繁的例子:单例,来说明顺序性导致的线程安全问题。这是一个双重检查单例模式的实现,在大部分情况下,程序都能正常运行,一旦发生顺序问题,就可以导致程序崩溃。

public class Singleton {

private static Singleton instance;

private Singleton(){

}

public static Singleton getInstance(){

if(instance==null){

synchronized(Singleton.class){

if(instance==null){

instance = new Singleton();

}

}

}

return instance;

}

}

new Singleton的创建过程分为:分配内存,在内存中初始化Instance,然M的地址赋值给Instance这三步,如果这三步的顺序被重排,按照如下执行。

可以看到,如果先把M的地址赋值给Instance这一步先执行,此时切换了线程B,线程B会判断instance不会空,然后返回未初始化的Singlenton,导致程序崩溃。

解决线程安全的方案


通过前面一节的讲解,我们已经知道了产生线程安全的原因,那么如何解决线程安全呢?主要有两个解决方案

  1. 确保任何时候,都只能有一个线程进入共享的程序段(临界区)

  2. 确保各个线程之间没有共享的资源(临界资源)

互斥或者同步可以实现任何时候,都只有一个线程能进入到临界区,线程本地变量如LocalThread可以实现线程间没有临界资源。下面详细介绍
一下互斥同步线程本地变量这三种实现线程安全的方法。

互斥

这里首先介绍一下实现互斥的几种常用的方式

  1. 屏蔽中断

  2. 软件实现的锁变量

  3. 软件实现的互斥算法

  4. 硬件指令

屏蔽中断

屏蔽中断是单核处理器系统中,实现互斥最简单的办法。某个线程进入临界区后立即屏蔽所有中断,屏蔽中断后,CPU便无法进行线程的切换,没有线程的切换,也就不会导致多个线程进入临界区引起的并发问题,当这个线程离开临界区时,再将中断打开。

但是这个方案有两个明显的缺点

  1. 用户进程不能拥有屏蔽中断的权利,如果用户进程拥有屏蔽中断的权利,那么随便一个恶意软件无限屏蔽中断就可以让系统无响应。所以只有系统进程才能使用这种方式实现互斥。

  2. 在多核处理器中,屏蔽中断只能屏蔽自己线程所处的CPU,而无法屏蔽其他CPU,这种情况下即使屏蔽也中断,依然还会有其他线程能进入临界区

因为这两个明显的缺点,屏蔽中断并不是一个比较好的互斥的方式

软件实现的锁变量

软件实现的锁变量是很多新手程序员常用的实现互斥的方式。我们经常会在程序中看到通过boolean或者int类型的变量来实现if else的判断,比如当值为true时,进入if的逻辑,false时,进入else的逻辑。

在前面已经说过,原子性是产生线程安全的原因之一,但是boolean或者int这些常用的基本类型的赋值操作是不具备原子性的,所以通过软件实现的锁变量用在临界区的互斥上,依然会导致多个线程都能进入临界区产生线程安全问题。

因此,软件实现的锁变量,也不是一个比较好的互斥的方式。

软件实现的互斥算法

比较有名的互斥算法是Peterson算法,这里简单的介绍一下这个算法的实现。

int turn;

int flat[2] = {false};

void engerRegion(int threadId){

flag[threadId] = true;

turn = 1 - threadId;

while(flat[threadId] == true&& turn == (1 - threadId)){};

//临界区

……

//退出临界区

flag[threadId] = false;

}

现在有两个线程P0和P1,当P0进入到临界区之前,会将flag[0]置为true,并将标志位turn设置为1(1-线程号0),然后通过while循环判断另一个线程的flag标志是否为trueflag[1] == true以及turn变量是否等于另一个线程号turn==1,如果是的话则说明另一个线程在临界区,会进入while循环等待,如果不是,则说明另一个线程没在临界区,可以进入临界区。

为什么这个算法能实现互斥呢?有这两种场景

  1. P0和P1同时都在执行这段代码

在这种情况下,线程P0将turn置为了1,这个时候线程P1又将turn置为了0。当线程P0进入while判断,对P0来说,这个时候flag[1-线程号]也就是flag[1] 为true,但是turn却是flase,不满足while循环的条件,所以P0会进入临界区。但是P1在while判断时,flag[0]为true,并且turn为0,满足while的条件,于是会一直循环,直到p0退出临界区。

  1. 只有一个线程在执行这段代码

在这种情况下,如果是P0在执行这段代码,while循环的判断中,flag[1]是false,不满足while循环的判断,于是P0会进入临界区。

可以看到,不管是同时只有一个线程在执行这段代码,还是同时两个线程都执行这段代码,都能保证只有一个线程进入临界区。这个时候会有人问,如果是三个线程,四个线程或者多个线程的情况呢?这就是Peterson算法的缺点,它只能满足两个线程的互斥,但是基于Peterson衍生出来的Filger算法能满足多个线程的互斥,Filger主要是将flag和turn进行了扩展,思路都是一样的,这里就不详讲了,它的实现如下。

// initialization

level[N] = { -1 }; // current level of processes 0…N-1

waiting[N-1] = { -1 }; // the waiting process of each level 0…N-2

// code for process #i

for(l = 0; l < N-1; ++l) {

level[i] = l;

waiting[l] = i;

while(waiting[l] == i &&

(there exists k ≠ i, such that level[k] ≥ l)) {

// busy wait

}

}

// critical section

level[i] = -1; // exit section

通过互斥算法可以实现互斥,但是软件实现的互斥在性能上不太好,特别是在大量的并发情况下,通过while循环遍历的方式,性能会更差。所以互斥算法也不是比较好的互斥的方式。

硬件指令

屏蔽中断,软件锁变量和互斥算法都不是很好的实现互斥的方式,那么有没有实现互斥的最好的办法呢?有,那就是硬件支持的互斥。

从早期处理器支持的:测试并设置(Test-and-Set), 获取并增加(Fetch-and-Increment),交换(Swap)。到现代处理器才开始支持的:比较并交换(Compare-and-Swap),加载链接/条件存储(Load-Linked/Store-Conditional)。这五种指令都是通过硬件支持的原子操作,通过这五种方式,我们就能比较完美的实现互斥。

硬件指令原子性的原理都是通过lock指令将内存总线锁住,以禁止其他CPU在本指令结束之前访问内存,内存总线只有一条,并且是独占的,不管多核还是单核,同一时间,只有一个CPU能占用总线。

这里详细介绍一下测试并设置(TAS)和比较并交换(CAS),加载链接/条件存储(LL/SC)**这两个硬件指令,因为这两个是用的最多的两个指令。

测试并设置(TAS)

TAS指令会向某个内存地址(这个内存地址只有1bit,所以值非0即1)写入值1,并且返回这块内存地址存的原始值。TAS指令是原子的,这是由实现TAS指令的硬件保证的(这里的硬件可以是CPU,也可以是实现了TAS的其他硬件),我们看一下如何通过TAS实现自旋锁。

byte lock = 0 //shared state

void engerRegion(){

//获取锁

while(test_and_set(lock)==1){}

// 临界区代码

……

//释放锁

lock = 0 //release

}

在while循环中,通过test_and_set对lock进行操作,如果lock返回值为1,说明已经有其他线程将lock的值设置成了1,所以会在while中不断的循环,知道其他线程退出临界区,将lock设置成了1,这次,这个线程就可以进入临界区,并且重新将lock设置成了1。通过空转循环检测的锁被称为自旋锁,TAS是Linux系统中实现自旋锁最常用的一种方式。

但是TAS也有一些缺点

  1. 第一点是TAS的值非0即1,这就导致通过TAS实现的锁只有两种状态,它用来实现互斥是很合适的,但是在一些更复杂的条件判断中,仅仅只通过互斥可能会满足不了要求。

  2. 第二个涉及到了TAS保证可见性的处理,当TAS每次将lock写为1时,都需要发送缓存一致性协议(缓存一致性协议后面会详细讲)通知其他Cache lock值已经失效,而发送协议需要占用总线流量,所以TAS在总线流量占用是比较大的。

比较并交换(CAS)

CAS指通过将某个内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值,CAS的逻辑比TAS要复杂很多,所以这里通过代码模拟CAS的过程。

boolean cas(long lock, long oldValue, long newValue)

{

if(lock != oldValue)

return false;

lock = newValue;

return true;

}

lock是我们的锁,当他和和指定的值oldValue不等时,CAS返回失败;如果相等时,CAS成功,将lock设置成新值。虽然这里面的步骤很多,但是整个流程由硬件支持的原子性操作。CAS的汇编指令是cmpxchg,它的实现如下。

#define LOCK_IF_MP(mp) __asm cmp mp, 0 \

__asm je L0 \

__asm _emit 0xF0 \

__asm L0:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {

// alternative for InterlockedCompareExchange

int mp = os::is_MP();

__asm {

mov edx, dest

mov ecx, exchange_value

mov eax, compare_value

LOCK_IF_MP(mp) // 如果是多处理器的话,则需要添加Lock锁住总线

cmpxchg dword ptr [edx], ecx

}

}

通过cmpxchg的汇编可以看到,如果是多处理器情况,会在指令添加lock前缀,lock是对原子性,可见性和顺序性的保证。

Intel的手册对lock前缀的说明如下:

  1. 确保对内存的读改写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。但这也会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。

  2. 禁止该指令与之前和之后的读和写指令重排序。

  3. 把值数据刷新到内存中。

cmpxchg只在Intel的架构上才支持,ARM架构并不支持CAS,所以在Android系统上的原子操作并不是通过CAS来实现的。

我们接着看一下如何通过CAS实现一个自旋锁。

int lock = 0 //shared state

void engerRegion(){

//获取锁

while(cas(lock,0,1)){}

// 临界区代码

……

//释放锁

lock = 0 //release

}

在while的条件判断中,lock会和oldValue比较是否相等,如果不等,说明有其他线程将lock设置成了1并进入了临界区,所以当前线程会通过while空循环不断的进行cas操作,知道其他线程退出临界区,将lock设置成了0,此时cas成功,并将lock改成了1。

CAS比TAS性能更好,是因为它改进了TAS中提到的两个缺点

  1. CAS的可以实现更复杂的条件判断,因为lock并不是byte,所以我们可以设置成任意一个数字代表了其中一个条件

  2. CAS只有在cas成功时,才会将lock的值写为期待值,也就是只会写一次值,所以只需要发送一次MESI广播,告知其他的cachelock已经被改写。

CAS解决了TAS的值非零即一的问题,但也带来了另外一个问题,也就是ABA问题。

int lock = 0 //shared state

void method1(){

//获取锁

while(cas(lock,0,1)){}

// 临界区代码

……

//释放锁

lock = 0 //release

}

void method2(){

//获取锁

while(cas(lock,1,0)){}

// 临界区代码

……

//释放锁

lock = 1 //release

}

如果有1,2,3三个线程,1和2线程访问method1,3线程访问method2。线程1和线程2都要访问method1,但是线程1先进入到method1的临界区,这个时候lock的值是1,线程2由于cas失败,会陷入while循环阻塞。这个时候线程3要访问method2,并且进入临界区的条件时lock值为1,线程3满足进入临界区的条件,但同时将lock值改为1,这个时候线程2cas成功了,于是线程1和线程2这个时候都在临界区,此时就有了线程安全问题。ABA问题总结起来就是一个值A改成了B,又被改成了A,但是cas检测不出被改动过。

为什么TAS没有ABA的问题呢?因为TAS进入临界区的条件是只有lock为零才能进入临界区,其他条件都无法进入临界区,所以它也无法实现上面这种多条件的并发任务。JVM通过给每个变量添加了版本号,来通过版本是否一致解决了ABA问题。

加载链接/条件存储(LL/SC)

为什么要介绍LL/SC呢?因为ARM平台并不支持CAS,只支持LL/SC,所以作为Android开发,需要对LL/SC能够了解。LL/SC和CAS在理论上是等价的,LL指令读取一个内存中的数据并存到一个寄存器,然后在寄存器修改这个值,随后用SC指令将它写回到原来的内存位置中。如果LL在修改值时失败,就代表了LL/SC失败,什么情况下LL会失败呢?收到MESI一致性协议广播时都会修改失败。LL/SC在ARM的汇编指令是LDREX和STREX。

可见性和顺序性

/释放锁

lock = 0 //release

}

void method2(){

//获取锁

while(cas(lock,1,0)){}

// 临界区代码

……

//释放锁

lock = 1 //release

}

如果有1,2,3三个线程,1和2线程访问method1,3线程访问method2。线程1和线程2都要访问method1,但是线程1先进入到method1的临界区,这个时候lock的值是1,线程2由于cas失败,会陷入while循环阻塞。这个时候线程3要访问method2,并且进入临界区的条件时lock值为1,线程3满足进入临界区的条件,但同时将lock值改为1,这个时候线程2cas成功了,于是线程1和线程2这个时候都在临界区,此时就有了线程安全问题。ABA问题总结起来就是一个值A改成了B,又被改成了A,但是cas检测不出被改动过。

为什么TAS没有ABA的问题呢?因为TAS进入临界区的条件是只有lock为零才能进入临界区,其他条件都无法进入临界区,所以它也无法实现上面这种多条件的并发任务。JVM通过给每个变量添加了版本号,来通过版本是否一致解决了ABA问题。

加载链接/条件存储(LL/SC)

为什么要介绍LL/SC呢?因为ARM平台并不支持CAS,只支持LL/SC,所以作为Android开发,需要对LL/SC能够了解。LL/SC和CAS在理论上是等价的,LL指令读取一个内存中的数据并存到一个寄存器,然后在寄存器修改这个值,随后用SC指令将它写回到原来的内存位置中。如果LL在修改值时失败,就代表了LL/SC失败,什么情况下LL会失败呢?收到MESI一致性协议广播时都会修改失败。LL/SC在ARM的汇编指令是LDREX和STREX。

可见性和顺序性

这篇关于掌握Android和Java线程原理上,阿里架构师深入讲解Android开发的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!