并行流中的I/O代码引发了SecurityException异常

12

我无法解释这个问题,但在别人的代码中发现了这个现象:

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.util.stream.Stream;

import org.junit.Test;

public class TestDidWeBreakJavaAgain
{
    @Test
    public void testIoInSerialStream()
    {
        doTest(false);
    }

    @Test
    public void testIoInParallelStream()
    {
        doTest(true);
    }

    private void doTest(boolean parallel)
    {
        Stream<String> stream = Stream.of("1", "2", "3");
        if (parallel)
        {
            stream = stream.parallel();
        }
        stream.forEach(name -> {
            try
            {
                Files.createTempFile(name, ".dat");
            }
            catch (IOException e)
            {
                throw new UncheckedIOException("Failed to create temp file", e);
            }
        });
    }
}

启用安全管理器后,仅在流上调用parallel()或在从集合获取流时调用parallelStream()似乎保证所有尝试执行I/O的操作都会抛出SecurityException。(最有可能的是,调用任何可能抛出SecurityException的方法,都将抛出该异常。)

我理解parallel()意味着它将在另一个线程中运行,该线程可能没有与我们开始时相同的特权,但我认为框架会为我们处理这个问题。

在整个代码库中删除对parallel()parallelStream()的调用可以避免风险。插入AccessController.doPrivileged也可以解决问题,但在某些情况下并不安全。还有其他选项吗?


2
请提供您收到的异常的堆栈跟踪。 - K Erlandsson
2
请提供您的SecurityManager代码,以便我们能够精确地复现您的问题。 - Tagir Valeev
1
这可能与使用公共fork join池的并行流有关。如果您提供自己的池,它是如何工作的? - assylias
这个问题也会影响存储在ThreadLocal中的上下文,例如Spring的SecurityContext,请参见例如此问题 - Andrew Spencer
2个回答

12

并行流执行将使用Fork/Join框架,具体来说它将使用Fork/Join公共池。这是一个实现细节,但正如在这种情况下观察到的那样,这些细节可能以意想不到的方式泄露出去。

请注意,使用CompletableFuture异步执行任务时也可能发生相同的行为。

当存在安全管理器时,Fork/Join公共池的线程工厂将被设置为创建“无害”线程的工厂。这样的“无害”线程没有被授予任何权限,不属于任何定义的线程组,并且在顶级Fork/Join任务完成其执行后,所有线程本地变量(如果已创建)都将被清除。这种行为确保了在共享公共池时Fork/Join任务彼此隔离。

这就是为什么在示例中会抛出SecurityException的原因:

java.lang.SecurityException: Unable to create temporary file or directory

有两个潜在的解决方法。根据使用安全管理器的原因,每个解决方法可能会增加不安全性的风险。

第一个更常见的解决方法是通过系统属性注册一个Fork/Join线程工厂,告诉Fork/Join框架公共池的默认线程工厂应该是什么。例如,这里是一个非常简单的线程工厂:

public class MyForkJoinWorkerThreadFactory
        implements ForkJoinPool.ForkJoinWorkerThreadFactory {
    public final ForkJoinWorkerThread newThread(ForkJoinPool pool) {
        return new ForkJoinWorkerThread(pool) {};
    }
}

可以使用以下系统属性进行注册:

-Djava.util.concurrent.ForkJoinPool.common.threadFactory=MyForkJoinWorkerThreadFactory

MyForkJoinWorkerThreadFactory 的行为目前等同于ForkJoinPool.defaultForkJoinWorkerThreadFactory

第二种更具体的解决方法是创建一个新的 Fork/Join 池。在这种情况下,构造函数不接受 ForkJoinWorkerThreadFactory 参数时将使用 ForkJoinPool.defaultForkJoinWorkerThreadFactory。任何并行流执行都需要从该池中执行的任务中执行。请注意,这是一些实现细节,在未来的版本中可能会发生更改。


好的回答。还要注意,提交的线程可能会在F/J框架中用作工作线程,这可能会对此产生影响: http://coopsoft.com/ar/Calamity2Article.html#submit - edharned
有趣的是,即使构建一个没有自定义任何内容的新ForkJoinPool,也会产生一个给我完全权限的池。只有从commonPool()返回的实例被他们的自定义工厂“毒化”了。Stream API似乎无法让我选择使用哪个池,所以我想我们只能避免使用它,而使用我们现有的并行执行工具。 - Hakanai
Trejkaz,你是正确的,我在审查代码时错过了那个。回答已更新以纠正错误。 - Paul Sandoz
我只想指出,在调试SecurityManager权限问题时,Oracle的故障排除指南可能非常有帮助。 - Elliott Brossard

2

您对AccessController.doPrivileged的担忧是不必要的。如果正确使用,它不会降低安全性。单个操作参数的版本将在您的上下文中执行操作,而忽略调用者,但是有一些重载方法,具有额外的参数——先前记录的上下文:

private void doTest(boolean parallel)
{
    Consumer<String> createFile=name -> {
        try {
            Files.createTempFile(name, ".dat");
        }
        catch (IOException e) {
            throw new UncheckedIOException("Failed to create temp file", e);
        }
    }, actualAction;
    Stream<String> stream = Stream.of("1", "2", "3");

    if(parallel)
    {
        stream = stream.parallel();
        AccessControlContext ctx=AccessController.getContext();
        actualAction=name -> AccessController.doPrivileged(
          (PrivilegedAction<?>)()->{ createFile.accept(name); return null; }, ctx);
    }
    else actualAction = createFile;

    stream.forEach(actualAction);
}

第一条重要的语句是AccessControlContext ctx=AccessController.getContext();,它记录了您当前的安全上下文,其中包括您的代码和当前调用者。(请记住,有效权限是所有调用者集合的交集)。通过将生成的上下文对象ctx提供给Consumer中的doPrivileged方法,您正在重新建立上下文,换句话说,PrivilegedAction将具有与单线程场景相同的权限。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接