发丘摸金,搬山卸岭,跨进程传输大数据

0.179字数 6435阅读 1733

不知道大家有没有看过《鬼吹灯》,里面的盗墓江湖分为四个流派,摸金发丘不必说,搬山卸岭都擅长以肉身之能发挥移山填海之力,端是神秘莫测,叹为观止。不知道众位施主有没有兴趣和贫道一起探索Android移山填海之术呢?不妨你认我做大哥,我教你梳中分……

鬼吹灯

引子

好了扯远了。我们今天要挑战的就是如何高效“优雅”地在各个进程之间传递比较大的数据——其实就是bitmap或者超过1M的其他什么数据。事情的起因是这样,公司接入了一个第三方的SDK,偶然间发现类似这样的一段代码:

private void letsGoDie(Context context, Bitmap bmp) {
        // 首先得到byte数组
        int bytes = bmp.getByteCount();
        ByteBuffer buf = ByteBuffer.allocate(bytes);
        bmp.copyPixelsToBuffer(buf);
        byte[] byteArray = buf.array();

        // 骚操作来了,走你!
        Intent it = new Intent(context, BoomActivity.class);
        Bundle bundle = new Bundle();
        // 这里不用纠结可以通过bundle.putParcelable()传bitmap,因为本质都是一样的
        bundle.putByteArray("boom", byteArray);
        context.startActivity(it);
    }

我们说,从一个Activity向另一个Activity传递bitmap方法有很多种。比如我笨一点,我是老实人,我先把bitmap保存到本地,然后传递一个Uri过去,在另一个Activity中把bitmap读取出来,并且删掉本地缓存。诚然,这种方式伴随着一系列磁盘IO操作效率低下,且非常不优雅。但是无所谓,谁叫我笨呢。
还有另一种方式,我把bitmap存为静态的,然后去另外一个Activity把bitmap拿到,但是千万记得,把bitmap的静态引用置空,不然面试你进公司可能是一起招聘事故。尽管你做到了小心翼翼,但是当别的程序员看到你这段代码时仍然会嗤之以鼻,这笔代码也许会成为你一生的污点。而且,我们撇开这些主观体验不谈,这种方式同样不能做到跨进程传输。
第三种方式,也是最常用的方式。既然跨Activity(跨进程)传递bitmap如此痛苦,那么我就不传。是的,别笑,这通常是很好的解决方案。我们知道bitmap是造成诸多内存问题的罪魁祸首,惹不起咱们躲得起。这不失为一个良策。

TransactionTooLargeException

但是我们不能就这点追求。在提出方案之前,我们先看看前面那个letsGoDie方法执行起来有何不妥。来一段demo:

private void letsGoDie(Context context) {
        byte[] bigData = new byte[1024 * 1024];
        Intent it = new Intent(context, TestActivity.class);
        it.putExtra("boom", bigData);
        context.startActivity(it);
    }

瞬间爆炸:

JavaBinder: !!! FAILED BINDER TRANSACTION !!!  (parcel size = 1049060)
AndroidRuntime: FATAL EXCEPTION: main
java.lang.RuntimeException: Unable to start activity ComponentInfo{XXActivity}: java.lang.RuntimeException: Failure from system
...
Caused by: java.lang.RuntimeException: Failure from system
...
Caused by: android.os.TransactionTooLargeException: data parcel size 1049060 bytes
at android.os.BinderProxy.transactNative(Native Method)
at android.os.BinderProxy.transact(Binder.java:628)
at android.app.ActivityManagerProxy.startActivity(ActivityManagerNative.java:3563)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1526)
at android.app.Activity.startActivityForResult(Activity.java:4403) 
at android.support.v4.app.BaseFragmentActivityJB.startActivityForResult(BaseFragmentActivityJB.java:54) 
at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:75) 
at android.app.Activity.startActivityForResult(Activity.java:4362) 
at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:708) 
at android.app.Activity.startActivity(Activity.java:4686) 
at android.app.Activity.startActivity(Activity.java:4654) 
at com.example.administrator.testapp.MainActivity.letsGoDie(MainActivity.java:67) 
at com.example.administrator.testapp.MainActivity.onCreate(MainActivity.java:28) 
at android.app.Activity.performCreate(Activity.java:6955) 
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1126) 
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2927) 
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3045) 
at android.app.ActivityThread.-wrap14(ActivityThread.java) 
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1642) 
at android.os.Handler.dispatchMessage(Handler.java:102) 
at android.os.Looper.loop(Looper.java:154) 
at android.app.ActivityThread.main(ActivityThread.java:6776) 
at java.lang.reflect.Method.invoke(Native Method) 
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:1520) 
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1410) 

从异常堆栈来看,是在做Binder通信,BinderProxy.transact()的时候抛出的。搜一下相关源码:

656 void signalExceptionForError(JNIEnv* env, jobject obj, status_t err,
657        bool canThrowRemoteException, int parcelSize)
658{
659    switch (err) {
           ...
702        case FAILED_TRANSACTION: {
703            ALOGE("!!! FAILED BINDER TRANSACTION !!!  (parcel size = %d)", parcelSize);
704            const char* exceptionToThrow;
705            char msg[128];
706            // TransactionTooLargeException is a checked exception, only throw from certain methods.
707            // FIXME: Transaction too large is the most common reason for FAILED_TRANSACTION
708            //        but it is not the only one.  The Binder driver can return BR_FAILED_REPLY
709            //        for other reasons also, such as if the transaction is malformed or
710            //        refers to an FD that has been closed.  We should change the driver
711            //        to enable us to distinguish these cases in the future.
712            if (canThrowRemoteException && parcelSize > 200*1024) {
713                // bona fide large payload
714                exceptionToThrow = "android/os/TransactionTooLargeException";
715                snprintf(msg, sizeof(msg)-1, "data parcel size %d bytes", parcelSize);
716            } else {
717                // Heuristic: a payload smaller than this threshold "shouldn't" be too
718                // big, so it's probably some other, more subtle problem.  In practice
719                // it seems to always mean that the remote process died while the binder
720                // transaction was already in flight.
721                exceptionToThrow = (canThrowRemoteException)
722                        ? "android/os/DeadObjectException"
723                        : "java/lang/RuntimeException";
724                snprintf(msg, sizeof(msg)-1,
725                        "Transaction failed on small parcel; remote process probably died");
726            }
727            jniThrowException(env, exceptionToThrow, msg);
728        } break;
           ...
776    }
777}

这里是err分析,那么这个异常是在哪里引发的呢?根据调用栈信息,我们往回搜一搜,找到transactNative声明对应的native方法:

1112 static jboolean android_os_BinderProxy_transact(JNIEnv* env, jobject obj,
1113        jint code, jobject dataObj, jobject replyObj, jint flags) // throws RemoteException
1114{
        ...
1152    //printf("Transact from Java code to %p sending: ", target); data->print();
1153    status_t err = target->transact(code, *data, reply, flags);
1154    //if (reply) printf("Transact from Java code to %p received: ", target); reply->print();
1155
1156    if (kEnableBinderSample) {
1157        if (time_binder_calls) {
1158            conditionally_log_binder_call(start_millis, target, code);
1159        }
1160    }
1161
1162    if (err == NO_ERROR) {
1163        return JNI_TRUE;
1164    } else if (err == UNKNOWN_TRANSACTION) {
1165        return JNI_FALSE;
1166    }
1167
1168    signalExceptionForError(env, obj, err, true /*canThrowRemoteException*/, data->dataSize());
1169    return JNI_FALSE;
1170}

这不得不说是个坏消息,看起来只要涉及Binder通信,都有可能爆雷。究竟是不是这样呢?我说了不算,看看官方的说法:

The Binder transaction failed because it was too large.

During a remote procedure call, the arguments and the return value of the call are transferred as Parcel objects stored in the Binder transaction buffer. If the arguments or the return value are too large to fit in the transaction buffer, then the call will fail and TransactionTooLargeException will be thrown.

The Binder transaction buffer has a limited fixed size, currently 1Mb, which is shared by all transactions in progress for the process. Consequently this exception can be thrown when there are many transactions in progress even when most of the individual transactions are of moderate size.

There are two possible outcomes when a remote procedure call throws TransactionTooLargeException. Either the client was unable to send its request to the service (most likely if the arguments were too large to fit in the transaction buffer), or the service was unable to send its response back to the client (most likely if the return value was too large to fit in the transaction buffer). It is not possible to tell which of these outcomes actually occurred. The client should assume that a partial failure occurred.

The key to avoiding TransactionTooLargeException is to keep all transactions relatively small. Try to minimize the amount of memory needed to create a Parcel for the arguments and the return value of the remote procedure call. Avoid transferring huge arrays of strings or large bitmaps. If possible, try to break up big requests into smaller pieces.

If you are implementing a service, it may help to impose size or complexity contraints on the queries that clients can perform. For example, if the result set could become large, then don't allow the client to request more than a few records at a time. Alternately, instead of returning all of the available data all at once, return the essential information first and make the client ask for additional information later as needed.

所有Binder通信传输的数据都是放在一个缓冲池(Binder transaction buffer)当中的。如果这个缓冲池被填满了装不下(超过1Mb),就会抛出这个异常。更蛋疼的是,这个池子并不是单次传输用的。对于一个进程(process),所有的Binder transaction都使用这个池子。一个进程既可以作为server端,又可以作为client端,每个端又可以同时做多个transaction,在做耗时任务的时候,这个池子里面的脏水还可能不能及时排放,这样的话,貌似这个池子很容易满呢。
那么这个问题应该怎么做处理呢?官方提出切片(If possible, try to break up big requests into smaller pieces)。似乎是个好主意。但是实际操作起来肯定痛不欲生。首先要把一个Parce包切割,如何切割?如何组合?切割完之后还需要分别发起多次Binder通信转移,转移完成之后再拼装,在这个过程中还要注意碎片的状态维护……我猜提出这个建议的Google程序员自己也没这么干过,有兴趣的小伙伴可以搜一下Android源码里面有没有这种操作。
这篇文章提出了做Binder transaction做监听,在这个池子将满的时候报警。这是个防爆雷的好方式,但是会影响到正常功能,池子满之后不得不放弃或者暂缓当前的正常交互流程,一旦出现这种情况往往也是不能接受的。


MemoryFile

说到底我们还是需要一种彻底解决这个困境的办法。想要传大数据,又不爆雷,还要快。天下有没有这样的好事?当然有。那就是MemoryFile(注:从API 27开始,可以使用SharedMemory来替代MemoryFile。相比MemoryFile,SharedMemory提供更丰富的功能,更安全的内存读写控制,当然还有最重要的,配合Parcel类(API 29),能够间接写入Parcelable数据。同时,SharedMemory本身是Parcelable的,能够直接通过Intent传递,避免了很多麻烦,后面会有分析。SharedMemory与MemoryFile原理类似,但是API要求较高,这里暂不讨论)。说到MemoryFile,很多人也不会陌生了。你也许会说,这个啊,我知道——但是,你可能还没有正式在项目中使用过。

我们先了解一下MemoryFile:

在Android系统中,提供了独特的匿名共享内存子系统Ashmem(Anonymous Shared Memory),它以驱动程序的形式实现在内核空间中。它有两个特点,一是能够辅助内存管理系统来有效地管理不再使用的内存块,二是它通过Binder进程间通信机制来实现进程间的内存共享。
Android系统的匿名共享内存子系统的主体是以驱动程序的形式实现在内核空间的,同时,在系统运行时库层和应用程序框架层提供了访问接口,其中,在系统运行时库层提供了C/C++调用接口,而在应用程序框架层提供了Java调用接口。这里,我们将直接通过应用程序框架层提供的Java调用接口来说明匿名共享内存子系统Ashmem的使用方法,毕竟我们在Android开发应用程序时,是基于Java语言的,而实际上,应用程序框架层的Java调用接口是通过JNI方法来调用系统运行时库层的C/C++调用接口,最后进入到内核空间的Ashmem驱动程序去的。
在Android应用程序框架层,提供了一个MemoryFile接口来封装了匿名共享内存文件的创建和使用。
——老罗的Android之旅:https://blog.csdn.net/luoshengyang/article/details/6651971

原理比较清楚了,尝试使用一下:

public class MemoryFileUtil {
    /**
     * 写入匿名共享内存
     * @param data 目标数据
     * @param length 数据长度
     * @return 匿名共享内存的文件描述符
     * */
    public static FileDescriptor write2Memory(byte[] data, int length) {
        try {
            MemoryFile mf = new MemoryFile("chicken dinner", length);
            mf.writeBytes(data, 0, 0, length);

            Method mGetDeclaredField = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
            mGetDeclaredField.setAccessible(true);

            return (FileDescriptor) mGetDeclaredField.invoke(mf);
        } catch (IOException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
        }

        return null;
    }
}

第一步,通过调用native方法native_open开辟共享内存区域(高版本API中已经被替换成了SharedMemory.mapReadWrite()了,这里按下不表)。这个区域被抽象成一个内存文件,所以开辟完成之后会返回一个文件描述符。返回文件描述符的方法是hide的,需要用反射获取一下。
最重要的分配匿名内存区竟然如此简单。那么,当我们要在另一个进程或者另外一个Activity中获取这份内存内容的时候怎么做呢?这个FileDescriptor既不是Serializable的,又不是Parcelable的,意味着无法序列化,也就没办法通过Intent传递。没关系,我们可以用ParcelFileDescriptor dup一下,ParcelFileDescriptor 实现了Parcelable接口,这下Intent可以传递了,完美!代码走一波:

ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(fd);

然后塞到Intent里面去,startActivity,接下来祈求佛主保佑。等等,好像不太顺利:

文件描述符传递之痛

java.lang.RuntimeException: Not allowed to write file descriptors here
at android.os.Parcel.nativeWriteFileDescriptor(Native Method)
at android.os.Parcel.writeFileDescriptor(Parcel.java:625)
at android.os.ParcelFileDescriptor.writeToParcel(ParcelFileDescriptor.java:975)
at android.os.Parcel.writeParcelable(Parcel.java:1505)
at android.os.Parcel.writeValue(Parcel.java:1411)
at android.os.Parcel.writeArrayMapInternal(Parcel.java:733)
at android.os.BaseBundle.writeToParcelInner(BaseBundle.java:1408)
at android.os.Bundle.writeToParcel(Bundle.java:1133)
at android.os.Parcel.writeBundle(Parcel.java:773)
at android.content.Intent.writeToParcel(Intent.java:9253)
at android.app.ActivityManagerProxy.startActivity(ActivityManagerNative.java:3545)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1526)
at android.app.Activity.startActivityForResult(Activity.java:4403)

什么?不允许传file descriptors?那这个ParcelFileDescriptor不是逗我吗?场面好像开始有点尴尬了,说静态变量存放ParcelFileDescriptor的同学麻烦出门右转,咱们继续想办法。
通读FileDescriptor代码,我们发现这个类其实很简单(至少表面上是),其关键的信息仅仅是一个整数descriptor。原来Java中的文件描述符如此直接,就是拿一个整数来编码的!改一下代码:so easy

public class MemoryFileUtil {
    /**
     * 写入匿名共享内存
     * @param data 目标数据
     * @param length 数据长度
     * @return 匿名共享内存的文件描述符对应的数字
     * */
    public static int write2Memory(byte[] data, int length) {
        try {
            MemoryFile mf = new MemoryFile("chicken dinner", length);
            mf.writeBytes(data, 0, 0, length);

            Method mGetDeclaredField = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
            mGetDeclaredField.setAccessible(true);

            FileDescriptor fd = (FileDescriptor) mGetDeclaredField.invoke(mf);
            Field fDescriptor = FileDescriptor.class.getDeclaredField("descriptor");
            fDescriptor.setAccessible(true);

            return fDescriptor.getInt(fd);
        } catch (IOException | NoSuchMethodException | IllegalAccessException | InvocationTargetException | NoSuchFieldException e) {
            e.printStackTrace();
        }

        return -1;
    }

    /**
     * 读取匿名共享内存
     * @param descriptor 文件描述符对应的整数
     * @param length 数据结果的长度
     * @return 数据结果,null表示读取失败
     * */
    public static byte[] getFromMemory(int descriptor, int length) {
        FileDescriptor fd = new FileDescriptor();

        try {
            Field fDescriptor = FileDescriptor.class.getDeclaredField("descriptor");
            fDescriptor.setAccessible(true);
            fDescriptor.set(fd, descriptor);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
            return null;
        }

        FileInputStream fis = new FileInputStream(fd);
        byte[] data = new byte[length];
        try {
            fis.read(data);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                fis.close();
            } catch (Exception e) {
                ;
            }
        }

        return data.length > 0 ? data : null;
    }
}

读写方法都具备了,赶紧在不同Activity之间实验一下。虽然感觉有哪里不对,但是结果完美,so easy,妈妈再也不用担心我年终kpi不达标了。
我们知道进程之间内存是不共享的,进程是操作系统分配资源的最小单位,这也是需要研究跨进程传递数据的原因。这里传递文件描述符对应的整数,似乎有哪里不对。不同进程之间是如何对这个整数对应的文件描述符,物理数据存储区达成共识的呢?一种可能是,操作系统维护了一个“物理存储区 - 文件描述符 - 对应的整数”的映射表,操作系统负责协调各个进程,并分配文件描述符(对应的整数);另一种可能是,直接传整数有问题。切换不同进程一试,果然出错了。

文件描述符跨进程变身

查询资料得知,要传递文件描述符,只有一个办法,即通过Binder通信。在最新的Kernal 3.18 /drivers/staging/android/binder.c中,可以找到 binder_transaction() 函数,该函数描述了文件描述符在Binder通信中的转换过程:

1314static void binder_transaction(struct binder_proc *proc,
1315                   struct binder_thread *thread,
1316                   struct binder_transaction_data *tr, int reply)
1317{
...
1540switch (fp->type) {
1541        case BINDER_TYPE_BINDER:
1542        case BINDER_TYPE_WEAK_BINDER: {
1543            ...
1585        } break;
1586        case BINDER_TYPE_HANDLE:
1587        case BINDER_TYPE_WEAK_HANDLE: {
1588            ...
1631        } break;
1632
1633        case BINDER_TYPE_FD: {
1634            int target_fd;
1635            struct file *file;
1636
1637            if (reply) {
1638                if (!(in_reply_to->flags & TF_ACCEPT_FDS)) {
1639                    binder_user_error("%d:%d got reply with fd, %d, but target does not allow fds\n",
1640                        proc->pid, thread->pid, fp->handle);
1641                    return_error = BR_FAILED_REPLY;
1642                    goto err_fd_not_allowed;
1643                }
1644            } else if (!target_node->accept_fds) {
1645                binder_user_error("%d:%d got transaction with fd, %d, but target does not allow fds\n",
1646                    proc->pid, thread->pid, fp->handle);
1647                return_error = BR_FAILED_REPLY;
1648                goto err_fd_not_allowed;
1649            }
1650
1651            file = fget(fp->handle);
1652            if (file == NULL) {
1653                binder_user_error("%d:%d got transaction with invalid fd, %d\n",
1654                    proc->pid, thread->pid, fp->handle);
1655                return_error = BR_FAILED_REPLY;
1656                goto err_fget_failed;
1657            }
1658            if (security_binder_transfer_file(proc->tsk, target_proc->tsk, file) < 0) {
1659                fput(file);
1660                return_error = BR_FAILED_REPLY;
1661                goto err_get_unused_fd_failed;
1662            }
1663            target_fd = task_get_unused_fd_flags(target_proc, O_CLOEXEC);
1664            if (target_fd < 0) {
1665                fput(file);
1666                return_error = BR_FAILED_REPLY;
1667                goto err_get_unused_fd_failed;
1668            }
1669            task_fd_install(target_proc, target_fd, file);
1670            trace_binder_transaction_fd(t, fp->handle, target_fd);
1671            binder_debug(BINDER_DEBUG_TRANSACTION,
1672                     "        fd %d -> %d\n", fp->handle, target_fd);
1673            /* TODO: fput? */
1674            fp->handle = target_fd;
1675        } break;
...

这里的fp->type表明了当前数据对象携带的数据类型。当type为BINDER_TYPE_FD的时候,判断reply的值。reply传入时值为cmd==BC_REPLY,我们这里cmd是BC_TRANSACTION,故reply值为0,直接看到file = fget(fp->handle),根据handle值得到file文件信息。接下来是两个异常流,不必理会。task_get_unused_fd_flags(target_proc, O_CLOEXEC)这句非常重要,在目标进程中获取一个空闲文件描述符。其实从这一步可以看出,不同进程之间文件描述符是不等价的,是各个进程独立分配的。而且要注意到,这里target_fd是个整形,这就是前面文件描述符数据结构最关键的那个整数descriptor了。我们简单关注一下空闲文件描述符是怎么获取的。

374static int task_get_unused_fd_flags(struct binder_proc *proc, int flags)
375{
376 struct files_struct *files = proc->files;
      ...
389 return __alloc_fd(files, 0, rlim_cur, flags);
390}

这里的__alloc_fd是一个linux内核文件描述符分配函数(代码基于Linux source code (v4.18.5) , fs/file.c):

/*
 * allocate a file descriptor, mark it busy.
 */
int __alloc_fd(struct files_struct *files,
           unsigned start, unsigned end, unsigned flags)
{
    unsigned int fd;
    int error;
    struct fdtable *fdt;

    spin_lock(&files->file_lock);
repeat:
    /* 拿到本进程的文件描述符表 */
    fdt = files_fdtable(files);
    fd = start;
    /* 文件描述符查找流程 */
    /* files->next_fd为上一次查找确定的下一个可用空闲的文件描述符,这样可以提高获取的效率 */
    if (fd < files->next_fd)
        fd = files->next_fd;

    if (fd < fdt->max_fds)
        fd = find_next_fd(fdt, fd);

    /*
     * N.B. For clone tasks sharing a files structure, this test
     * will limit the total number of files that can be opened.
     */
    error = -EMFILE;
    if (fd >= end)
        goto out;

    /* 如需要则扩展文件描述符表 */
    error = expand_files(files, fd);
    if (error < 0)
        goto out;

    /*
     * If we needed to expand the fs array we
     * might have blocked - try again.
     */
    if (error)
        goto repeat;

    /* 设置next_fd,用于下次加速查找空闲的fd */
    if (start <= files->next_fd)
        files->next_fd = fd + 1;

    /* 将fd添加到已打开的文件描述符表中 */
    __set_open_fd(fd, fdt);
    if (flags & O_CLOEXEC)
        __set_close_on_exec(fd, fdt);
    else
        __clear_close_on_exec(fd, fdt);
    error = fd;
#if 1
    /* Sanity check */
    if (rcu_access_pointer(fdt->fd[fd]) != NULL) {
        printk(KERN_WARNING "alloc_fd: slot %d not NULL!\n", fd);
        rcu_assign_pointer(fdt->fd[fd], NULL);
    }
#endif

out:
    spin_unlock(&files->file_lock);
    return error;
}

从上面可以看到文件描述符的分配步骤,拿到文件描述符表之后,优先获取最小的文件描述符,在这个过程中会伴随查找优化。
最后,回到binder_transaction调用task_fd_install(target_proc, target_fd, file)将目标进程,文件数据结构,文件描述符绑定在一起,文件描述符的跨进程传输就完成了。


新的方案

文件描述符的获取流程我们清楚了,接下来回到眼前的苟且,功能开发上面。方案上,我们可以借助Messenger轻松地实现一个跨进程的client-server架构。进程A将大数据写入匿名共享内存,并将文件描述符用ParcelFileDescriptor包裹,存放在用于Binder通信的服务当中。进程B作为c端首先获取文件描述符,此时进程A成为s端。进程B拿到文件描述符之后直接利用FileInputStream读取文件内容。这种方案进程A既作为大数据写入方,又作为文件描述符Service提供方,B进程要获取大数据与A进程耦合是比较严重的。另外一种方案个人更加青睐。即存在一个中介服务,专门用于维护文件描述符列表。进程A写入大数据完成,将文件描述符存放在中介服务,进程B从中介服务中获取文件描述符,继续业务流程。

public class MemoryFileUtil {
    /**
     * 根据key值写入匿名共享内存
     * @param context
     * @param key 匿名共享内存对应的key值
     * @param data 目标数据
     * @param length 数据长度
     * */
    public static void write2Memory(Context context, String key, byte[] data, int length) {
        ParcelFileDescriptor pfd = null;
        MemoryFile mf = null;
        try {
            mf = new MemoryFile("chicken dinner", length);
            mf.writeBytes(data, 0, 0, length);

            Method mGetDeclaredField = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
            mGetDeclaredField.setAccessible(true);
            FileDescriptor fd = (FileDescriptor) mGetDeclaredField.invoke(mf);
            pfd = ParcelFileDescriptor.dup(fd);

            mf.close();
        } catch (IOException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
            e.printStackTrace();

            if (null != mf) {
                mf.close();
            }

            return;
        }
        
        send2FDServer(context, key, pfd);
    }
    
    public static final int MSG_WHAT_SEND_FD = 100;
    private static Messenger ms = null;
    
    /**
     * 把ParcelFileDescriptor发送到Server端
     * 注意,本方法仅作为说明的样例,实际工程中需要优化
     * @param context 
     * @param key 匿名共享内存对应的key值
     * @param pfd 目标文件描述符
     * */
    public static void send2FDServer(Context context, final String key, final ParcelFileDescriptor pfd) {
        ServiceConnection sc = new ServiceConnection() {
            @Override
            public void onServiceConnected(ComponentName name, IBinder service) {
                HashMap<String, ParcelFileDescriptor> params = new HashMap<>(1);
                params.put(key, pfd);
                
                ms = new Messenger(service);
                Message msg = Message.obtain();
                msg.what = MSG_WHAT_SEND_FD;
                msg.obj = params;

                try {
                    ms.send(msg);
                } catch (RemoteException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void onServiceDisconnected(ComponentName name) {
                ms = null;
            }
        };

        context.bindService(new Intent(context, FileDescriptorService.class), sc, Context.BIND_AUTO_CREATE);
    }
}

写代码也要用户体验!

到这里,似乎一切进展顺利。用起来怎么样呢?我们注意到,使用这个方法的时候需要传递一个length参数。MemoryFile在申请匿名共享内存时,申请多了浪费,申请少了装不下,所以需要这个参数用来告诉MemoryFile需要开辟多少内存空间。这个参数会让人很不爽,用户体验糟糕。如果只是传一个bitmap或者一个大的String,那非常容易获取对应的byte数组,length长度自然也容易获取。但是如果我们要一次性传两个bitmap怎么办?我要传String和bitmap的混合体怎么办?举个例子:

        HashMap<String, Serializable> complexData = new HashMap<>();
        complexData.put("string", "test string");
        complexData.put("bitmap1", bmp1);
        complexData.put("bitmap2", bmp2);

        send complextData by MemoryFile...

我的目标是把complexData整个塞到匿名共享内存中去。由于HashMap本身是Serializable的,成员构成也可以序列化,所以理论上整个对象都是可以传递的。但是回到最初的问题,这个length的计算就让人头疼了。你可千万别说这个length的值等于string + bitmap1 + bitmap2的byte数组长度,计算机老师想打人。那么这个length可以让MemoryFile帮我们计算吗?答案是不能!我们看看计算一个对象所占内存空间是多么的让人欲仙欲死吧:

如何计算Java对象所占内存的大小

看着就非常让人脑瓜疼,脑瓜疼,所以MemoryFile不会干这个事情。那么,我们自己能干这件事吗?我们注意到,利用ObjectOutputStream把序列化数据写入物理存储区的时候,并不需要我们提前计算数据长度。下面从writeObject()开始,扒开源码看一看这里面究竟有什么蹊跷。
writeObject()实际调用writeObject0()。在writeObject0()中分别对当前对象的类型进行了判断。我们分别看写入基本数据类型writeString()和写入序列化对象writeOrdinaryObject()两个方法。在writeString()方法中,实际调用的是BlockDataOutputStream.writeUTF()方法。而在这个方法中,会将字节流写入缓冲池buf中。最终会调用

        void drain() throws IOException {
            if (pos == 0) {
                return;
            }
            if (blkmode) {
                writeBlockHeader(pos);
            }
            out.write(buf, 0, pos);
            pos = 0;
            // Android-added: Warning about writing to closed ObjectOutputStream
            warnIfClosed();
        }

这个方法非常重要,它会把缓冲池中的数据流一次性调用out对象的write()方法写入。这个out对象是OutputStream类型的,在ObjectOutputStream的构造函数中作为参数传入。
writeOrdinaryObject()会进入writeSerialData()中,然后进入defaultWriteFields()。在这个方法中首先写入所有的基本数据类型成员,调用的是BlockDataOutputStream.write()方法。如果不是基本类型,那么遍历每个成员,递归调用writeObject0()来写入每个成员。所有这里的关键方法是BlockDataOutputStream.write():

        void write(byte[] b, int off, int len, boolean copy)
            throws IOException
        {
            if (!(copy || blkmode)) {           // write directly
                drain();
                out.write(b, off, len);
                // Android-added: Warning about writing to closed ObjectOutputStream
                warnIfClosed();
                return;
            }

            while (len > 0) {
                if (pos >= MAX_BLOCK_SIZE) {
                    drain();
                }
                if (len >= MAX_BLOCK_SIZE && !copy && pos == 0) {
                    // avoid unnecessary copy
                    writeBlockHeader(MAX_BLOCK_SIZE);
                    out.write(b, off, MAX_BLOCK_SIZE);
                    off += MAX_BLOCK_SIZE;
                    len -= MAX_BLOCK_SIZE;
                } else {
                    int wlen = Math.min(len, MAX_BLOCK_SIZE - pos);
                    System.arraycopy(b, off, buf, pos, wlen);
                    pos += wlen;
                    off += wlen;
                    len -= wlen;
                }
            }
            // Android-added: Warning about writing to closed ObjectOutputStream
            warnIfClosed();
        }

从这个方法我们可以看到,最终都会调用到out.write()方法去。与前面的drain()的殊途同归。那我们看看这个write()方法:

public void write(byte b[], int off, int len) throws IOException {
        if (b == null) {
            throw new NullPointerException();
        } else if ((off < 0) || (off > b.length) || (len < 0) ||
                   ((off + len) > b.length) || ((off + len) < 0)) {
            throw new IndexOutOfBoundsException();
        } else if (len == 0) {
            return;
        }
        for (int i = 0 ; i < len ; i++) {
            write(b[off + i]);
        }
    }

这个方法做的就是,循环byte数组,每个byte调用一次write(int b)。而这个方法是个抽象方法:

public abstract void write(int b) throws IOException;

绕了一大圈,终于柳暗花明,找到合适的办法了。总结一下流程:首先判断要写入的对象是不是基本类型,如果是,那么写入对象代码,写入对象本身的数据(byte[]);如果要写入的对象不是基本类型,那么遍历它的成员,重复上面的步骤。最终都会调用到OutputStream.write(int b)。所以我们只需要拦截这个方法就可以计算出序列化对象的大小了:

class SerializableSizeCalculator {
        public static int getSerializableSize(Serializable data) {
            if (null == data) {
                return -1;
            }

            MyOutputStream mos = new MyOutputStream();
            ObjectOutputStream oom = null;
            try {
                oom = new ObjectOutputStream(mos);
                oom.writeObject(data);
                return mos.count;
            } catch (Exception e) {
                return -1;
            } finally {
                try {
                    mos.close();
                    oom.close();
                } catch (Exception e) {
                }
            }
        }

        private static class MyOutputStream extends OutputStream {
            int count = 0;

            @Override
            public void write(int b) {
                // 只做计算,不真正写入数据
                count++;
            }
        }
    }

现在写入匿名共享内存的方法变成了:

    /**
     * 根据key值写入匿名共享内存
     * @param context
     * @param key 匿名共享内存对应的key值
     * @param data 目标数据
     * */
    public static void write2Memory(Context context, String key, Serializable data) {
        int size = SerializableSizeCalculator.getSerializableSize(data);

        ParcelFileDescriptor pfd = null;
        MemoryFile mf = null;
        ObjectOutputStream oos = null;
        boolean goSend = true;
        try {
            mf = new MemoryFile("chicken dinner", size);
            oos = new ObjectOutputStream(mf.getOutputStream());
            oos.writeObject(data);

            Method mGetDeclaredField = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
            mGetDeclaredField.setAccessible(true);
            FileDescriptor fd = (FileDescriptor) mGetDeclaredField.invoke(mf);
            pfd = ParcelFileDescriptor.dup(fd);

            mf.close();
        } catch (IOException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
            e.printStackTrace();
            
            goSend = false;
        } finally {
            if (null != mf) {
                mf.close();
            }
            
            try {
                oos.close();
            } catch (Throwable e) {
                ;
            }
        }

        if (goSend) {
            send2FDServer(context, key, pfd);
        }
    }

嗯,感觉舒服很多了。



匿名共享内存管理原理

事情到这里似乎要告一段落了,文章篇幅也很长了,能看到这里的都是狠人。但是,舒舒服服地用完了系统提供的MemoryFile,这玩意就真的安全吗?我们大刀阔斧地在内存空间里攻城略地,就不会有副作用产生?App上线之后我们就睡得着觉?


……
……
……
看来还是要深究一下匿名共享内存的内存管理机制。
我们以最新的MemoryFile代码为准。新版本MemoryFile代码封装了SharedMemory代码,功能更强大,内存回收更完善。

    public MemoryFile(String name, int length) throws IOException {
        try {
            mSharedMemory = SharedMemory.create(name, length);
            mMapping = mSharedMemory.mapReadWrite();
        } catch (ErrnoException ex) {
            ex.rethrowAsIOException();
        }
    }

MemoryFile构造函数初始化了两个十分重要的变量,mSharedMemory和mMapping。前者用于封装匿名共享内存的开辟,关闭等操作,后者映射了匿名共享内存区域,可以快速读写匿名共享内存。我们挨个分析。

    public static @NonNull SharedMemory create(@Nullable String name, int size)
            throws ErrnoException {
        ...
        return new SharedMemory(nCreate(name, size));
    }

首先看到nCreate(),是一个native方法,用于创建ashmem内存。代码位于/frameworks/base/native/android/sharedmem.cpp:

21int ASharedMemory_create(const char *name, size_t size) {
        ...
25    return ashmem_create_region(name, size);
26}

// 代码位于/system/core/libcutils/ashmem-dev.c
149int ashmem_create_region(const char *name, size_t size)
150{
151    int ret, save_errno;
152
153    int fd = __ashmem_open();
154    if (fd < 0) {
155        return fd;
156    }
157
158    if (name) {
159        char buf[ASHMEM_NAME_LEN] = {0};
160
161        strlcpy(buf, name, sizeof(buf));
162        ret = TEMP_FAILURE_RETRY(ioctl(fd, ASHMEM_SET_NAME, buf));
163        if (ret < 0) {
164            goto error;
165        }
166    }
167
168    ret = TEMP_FAILURE_RETRY(ioctl(fd, ASHMEM_SET_SIZE, size));
169    if (ret < 0) {
170        goto error;
171    }
172
173    return fd;
174
175error:
176    save_errno = errno;
177    close(fd);
178    errno = save_errno;
179    return ret;
180}

上面这个ashmem_create_region()函数是个老面孔了。__ashmem_open()打开设备文件ASHMEM_DEVICE,两个ioctl操作分别设置匿名共享内存名字和大小。这里不再深入解析,详细可以参阅老罗的Ashmem驱动分析
Ashmem内存分配之后,当我们使用命令

adb shell dumpsys meminfo package_name

的时候,就能看到ashmem内存块了。回到Java层调用,将返回的FileDescriptor传给SharedMemory的构造函数:

    private SharedMemory(FileDescriptor fd) {
        ...
        mFileDescriptor = fd;
        mSize = nGetSize(mFileDescriptor);
        ...
        mMemoryRegistration = new MemoryRegistration(mSize);
        mCleaner = Cleaner.create(mFileDescriptor,
                new Closer(mFileDescriptor, mMemoryRegistration));
    }

首先创建了MemoryRegistration。这个MemoryRegistration是啥?

private static final class MemoryRegistration {
        private int mSize;
        private int mReferenceCount;

        private MemoryRegistration(int size) {
            mSize = size;
            mReferenceCount = 1;
            VMRuntime.getRuntime().registerNativeAllocation(mSize);
        }

        public synchronized MemoryRegistration acquire() {
            mReferenceCount++;
            return this;
        }

        public synchronized void release() {
            mReferenceCount--;
            if (mReferenceCount == 0) {
                VMRuntime.getRuntime().registerNativeFree(mSize);
            }
        }
    }

这里面有一个关键方法VMRuntime.getRuntime().registerNativeAllocation(),它告诉了JVM,通过匿名内存申请了mSize这么多native内存,向JVM坦白了偷内存的犯罪事实,仅仅是一个声明的作用。为什么这么做呢?后面会有答案。理所当然的,当这块native内存不再使用的时候,就告诉JVM已经释放free掉了。
回到SharedMemory构造函数。下面通过sun.misc.Cleaner创建了一个对象。这个Cleaner来头可不小,它专门用于监控无法被JVM释放的内存。构造函数传入两个参数,一个是监控对象,这里是FileDescriptor对应的内存区域。Cleaner利用虚引用(PhantomReference)和ReferenceQueue来监控一个对象是否存在强引用。虚引用不影响对象任何的生命周期,当这个对象不具有强引用的时候,JVM会将这个对象加入与之关联的ReferenceQueue。此时Cleaner将会调用构造函数的第二个参数——一个Closer对象——实际上是一个Runnable来执行内存回收操作。我们看看这个Closer做了什么:

private static final class Closer implements Runnable {
        private FileDescriptor mFd;
        private MemoryRegistration mMemoryReference;

        private Closer(FileDescriptor fd, MemoryRegistration memoryReference) {
            mFd = fd;
            mMemoryReference = memoryReference;
        }

        @Override
        public void run() {
            try {
                Os.close(mFd);
            } catch (ErrnoException e) { /* swallow error */ }
            mMemoryReference.release();
            mMemoryReference = null;
        }
    }

这下就比较明了了,首先关闭文件描述符,然后释放MemoryReference——free掉之前对JVM的内存占用声明。
至此,SharedMemory的初始化分析完毕。接下来回到MemoryFile的构造函数中,还调用了

mMapping = mSharedMemory.mapReadWrite();

最终调用:

public @NonNull ByteBuffer map(int prot, int offset, int length) throws ErrnoException {
        ...
        long address = Os.mmap(0, length, prot, OsConstants.MAP_SHARED, mFileDescriptor, offset);
        boolean readOnly = (prot & OsConstants.PROT_WRITE) == 0;
        Runnable unmapper = new Unmapper(address, length, mMemoryRegistration.acquire());
        return new DirectByteBuffer(length, address, mFileDescriptor, unmapper, readOnly);
    }

Os.mmap()是将文件(匿名共享内存)映射到当前的进程用户空间,拿到始址,方便下面的DirectByteBuffer使用。这里的Unmapper顾名思义,是mmap的反向操作,注销映射。此外,Unmapper还做mMemoryReference的释放。你要问我是不是和前面Closer的重复操作了,我只能说是的,但是加个保险也没毛病。接下来又涉及一个重要的角色DirectByteBuffer。
DirectByteBuffer分配的内存区域是为堆外内存。所谓堆外,就是不属于JVM的管辖范围,直接由C通过malloc来分配的内存区块。DirectByteBuffer直译就是“直接缓冲”,也就是速度快。它直接对内存进行读写,不需要JVM中介,无需用户态和内核态的频繁切换。由于我们这里传入了匿名内存地址以及文件描述符,内存已经分配好了,不用DirectByteBuffer再分配,所以这里利用的其实是DirectByteBuffer快这个特点。
好了,所有的初始化准备工作已经就绪,回到MemoryFile类中。总结一下,经过了这一系列的繁复操作,MemoryFile达到了以下目的:

(1)开辟了可以跨进程共享的内存区块,用文件描述符访问;
(2)将不桀骜不驯的Ashmem内存(堆外内存)纳入JVM的监控范围。虽然JVM仍然无法直接释放它们,但是JVM可以通知我们,由我们自己选择释放,降低了内存泄露风险;
(3)通过DirectByteBuffer来访问这块内存区,提高速度;

至此,在高版本系统中,我们可以放心大胆使用MemoryFile了,因为它虽然凶猛,但是还是被套上了缰绳。然而,盲目开辟大块虚拟内存仍然是不建议的,毫无疑问,这将影响系统的吞吐能力。而且,开辟的Ashmem内存虽然没有OOM风险,但是仍然是算在开辟者身上的,你的所作所为系统都在暗中观察,当你耗用了大量系统资源的时候,总有秋后算账的时候。



最后的最后,我在前面的示例中,使用了mf.getOutputStream()来写入数据,实际上这么做并没有享受到DirectByteBuffer的政策红利。为什么呢?我们看看MemoryFile的API:

public void writeBytes(byte[] buffer, int srcOffset, int destOffset, int count)
            throws IOException {
        beginAccess();
        try {
            mMapping.position(destOffset);
            mMapping.put(buffer, srcOffset, count);
        } finally {
            endAccess();
        }
    }

也即只有调用MemoryFile的writeBytes()方法才可以使用DirectByteBuffer写入数据。因此如果传递的是规整的已知大小的数据,最好还是调用MemoryFile.writeBytes()来的高效。我在前面这么做仅仅是为了兼顾写入Serializable数据而已。

后记,再思考

没想到吧,还有后记!回顾我们一路折腾过来,虽然找到了解决方案,但是它优雅么?我们知道,从Android P开始反射调用hide API将受限,也就是说,从我们使用反射获取文件描述符开始,就不再优雅了。这个问题我们可以在高版本中用SharedMemory来代替,直接透传SharedMemory对象,不用获取文件描述符。但不能使用SharedMemory的版本中反射+Messenger传递文件描述符实在很难说服我自己这是优雅的实现方式,那这一切是否有违初心呢?或许真的将大数据序列化到本地再反序列化回来,虽然牺牲了性能但这才是最优雅的方式?又或者不传大数据才是最完美的方案?我觉得这些考虑都有道理,但是一方面这套方案能够解决实际的问题,虽然它已经不优雅了,但它性能确实更好;另一方面,我们本身在探索过程中也得到了很多乐趣,不是么。

推荐阅读更多精彩内容